diff --git a/crates/goose-cli/src/cli.rs b/crates/goose-cli/src/cli.rs index e0083d774200..bb9a0c12df53 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_info, 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,69 @@ 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, + }, + + /// Print session info for prompt integration + #[command( + about = "Print session info for prompt integration", + long_about = "Prints compact session info (token usage, model) for shell prompt integration.\n\ + Example output: ●○○○○ sonnet" + )] + Info, } #[derive(clap::ValueEnum, Clone, Debug)] @@ -838,6 +910,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 +954,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 +1468,29 @@ 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?; + } + TermCommand::Info => { + handle_term_info().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 698fd02fdfe4..e0d54e96780b 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 000000000000..8352be449037 --- /dev/null +++ b/crates/goose-cli/src/commands/term.rs @@ -0,0 +1,243 @@ +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:"; + +/// Ensure a terminal session exists, creating it if necessary +async fn ensure_terminal_session( + session_name: String, + working_dir: std::path::PathBuf, +) -> Result<()> { + if SessionManager::get_session(&session_name, false) + .await + .is_err() + { + let session = SessionManager::create_session_with_id( + session_name.clone(), + working_dir, + session_name.clone(), + SessionType::User, + ) + .await?; + + SessionManager::update_session(&session.id) + .user_provided_name(session_name) + .apply() + .await?; + } + Ok(()) +} + +/// 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($|[[:space:]]) ]] && 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($|[[:space:]]) ]] && 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}" +function gt; {goose_bin} term run $argv; end + +# 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($|\s)' -- $argv[1]; and return + {goose_bin} term log "$argv[1]" 2>/dev/null & +end"# + ) + } + "powershell" | "pwsh" => { + format!( + r#"$env:GOOSE_TERMINAL_ID = "{terminal_id}" +function gt {{ & '{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($|\s)') {{ + 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()?; + + ensure_terminal_session(session_name.clone(), working_dir.clone()).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 working_dir = std::env::current_dir()?; + + let session_id = match SessionManager::get_session(&session_name, false).await { + Ok(_) => { + SessionManager::update_session(&session_name) + .working_dir(working_dir) + .apply() + .await?; + session_name.clone() + } + Err(_) => { + ensure_terminal_session(session_name.clone(), working_dir).await?; + session_name.clone() + } + }; + + 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(()) +} + +/// Handle `goose term info` - print compact session info for prompt integration +pub async fn handle_term_info() -> Result<()> { + use goose::config::Config; + + let terminal_id = match std::env::var("GOOSE_TERMINAL_ID") { + Ok(id) => id, + Err(_) => return Ok(()), // Silent exit if no terminal ID + }; + + let session_name = format!("{}{}", TERMINAL_SESSION_PREFIX, terminal_id); + + // Get tokens from session or 0 if none started yet in this terminal + let session = SessionManager::get_session(&session_name, false).await.ok(); + let total_tokens = session.as_ref().and_then(|s| s.total_tokens).unwrap_or(0) as usize; + + let model_name = Config::global() + .get_goose_model() + .ok() + .or_else(|| { + session + .as_ref() + .and_then(|s| s.model_config.as_ref().map(|mc| mc.model_name.clone())) + }) + .map(|name| { + // Extract short name: after last / or after last - if it starts with "goose-" + let short = name.rsplit('/').next().unwrap_or(&name); + if let Some(stripped) = short.strip_prefix("goose-") { + stripped.to_string() + } else { + short.to_string() + } + }) + .unwrap_or_else(|| "?".to_string()); + + // Get context limit for the model + let context_limit = session + .as_ref() + .and_then(|s| s.model_config.as_ref().map(|mc| mc.context_limit())) + .unwrap_or(128_000); + + // Calculate percentage and create dot visualization + let percentage = if context_limit > 0 { + ((total_tokens as f64 / context_limit as f64) * 100.0).round() as usize + } else { + 0 + }; + + let filled = (percentage / 20).min(5); + let empty = 5 - filled; + let dots = format!("{}{}", "●".repeat(filled), "○".repeat(empty)); + + println!("{} {}", dots, model_name); + + Ok(()) +} diff --git a/crates/goose/src/session/session_manager.rs b/crates/goose/src/session/session_manager.rs index 7d63e665b45c..129324ad8e03 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 }) } @@ -759,88 +812,96 @@ impl SessionStorage { async fn apply_migration(&self, version: i32) -> Result<()> { match version { - 1 => { - sqlx::query( - r#" - CREATE TABLE IF NOT EXISTS schema_version ( - version INTEGER PRIMARY KEY, - applied_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP - ) - "#, - ) - .execute(&self.pool) - .await?; - } - 2 => { - sqlx::query( - r#" - ALTER TABLE sessions ADD COLUMN user_recipe_values_json TEXT - "#, - ) - .execute(&self.pool) - .await?; - } - 3 => { - sqlx::query( - r#" - ALTER TABLE messages ADD COLUMN metadata_json TEXT - "#, - ) - .execute(&self.pool) - .await?; - } - 4 => { - sqlx::query( - r#" - ALTER TABLE sessions ADD COLUMN name TEXT DEFAULT '' - "#, - ) - .execute(&self.pool) - .await?; + 1 => self.migrate_v1_schema_version().await?, + 2 => self.migrate_v2_recipe_values().await?, + 3 => self.migrate_v3_message_metadata().await?, + 4 => self.migrate_v4_session_name().await?, + 5 => self.migrate_v5_session_type().await?, + 6 => self.migrate_v6_provider_config().await?, + 7 => self.migrate_v7_shell_commands().await?, + _ => anyhow::bail!("Unknown migration version: {}", version), + } + Ok(()) + } - sqlx::query( - r#" - ALTER TABLE sessions ADD COLUMN user_set_name BOOLEAN DEFAULT FALSE - "#, - ) - .execute(&self.pool) - .await?; - } - 5 => { - sqlx::query( - r#" - ALTER TABLE sessions ADD COLUMN session_type TEXT NOT NULL DEFAULT 'user' - "#, - ) - .execute(&self.pool) - .await?; + async fn migrate_v1_schema_version(&self) -> Result<()> { + sqlx::query( + r#" + CREATE TABLE IF NOT EXISTS schema_version ( + version INTEGER PRIMARY KEY, + applied_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP + ) + "#, + ) + .execute(&self.pool) + .await?; + Ok(()) + } - sqlx::query("CREATE INDEX idx_sessions_type ON sessions(session_type)") - .execute(&self.pool) - .await?; - } - 6 => { - sqlx::query( - r#" - ALTER TABLE sessions ADD COLUMN provider_name TEXT - "#, - ) - .execute(&self.pool) - .await?; + async fn migrate_v2_recipe_values(&self) -> Result<()> { + sqlx::query("ALTER TABLE sessions ADD COLUMN user_recipe_values_json TEXT") + .execute(&self.pool) + .await?; + Ok(()) + } - sqlx::query( - r#" - ALTER TABLE sessions ADD COLUMN model_config_json TEXT - "#, - ) - .execute(&self.pool) - .await?; - } - _ => { - anyhow::bail!("Unknown migration version: {}", version); - } - } + async fn migrate_v3_message_metadata(&self) -> Result<()> { + sqlx::query("ALTER TABLE messages ADD COLUMN metadata_json TEXT") + .execute(&self.pool) + .await?; + Ok(()) + } + + async fn migrate_v4_session_name(&self) -> Result<()> { + sqlx::query("ALTER TABLE sessions ADD COLUMN name TEXT DEFAULT ''") + .execute(&self.pool) + .await?; + sqlx::query("ALTER TABLE sessions ADD COLUMN user_set_name BOOLEAN DEFAULT FALSE") + .execute(&self.pool) + .await?; + Ok(()) + } + + async fn migrate_v5_session_type(&self) -> Result<()> { + sqlx::query("ALTER TABLE sessions ADD COLUMN session_type TEXT NOT NULL DEFAULT 'user'") + .execute(&self.pool) + .await?; + sqlx::query("CREATE INDEX idx_sessions_type ON sessions(session_type)") + .execute(&self.pool) + .await?; + Ok(()) + } + async fn migrate_v6_provider_config(&self) -> Result<()> { + sqlx::query("ALTER TABLE sessions ADD COLUMN provider_name TEXT") + .execute(&self.pool) + .await?; + sqlx::query("ALTER TABLE sessions ADD COLUMN model_config_json TEXT") + .execute(&self.pool) + .await?; + Ok(()) + } + + async fn migrate_v7_shell_commands(&self) -> Result<()> { + 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?; Ok(()) } @@ -883,6 +944,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 +1389,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 +1644,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 000000000000..d41b8c6c3d2e --- /dev/null +++ b/documentation/docs/guides/terminal-integration.md @@ -0,0 +1,78 @@ +--- +unlisted: true +--- +# 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.