diff --git a/Cargo.lock b/Cargo.lock index db81d97590ef..f9e40f5d89e9 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1992,7 +1992,16 @@ version = "5.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "44c45a9d03d6676652bcb5e724c7e988de1acad23a711b5217ab9cbecbec2225" dependencies = [ - "dirs-sys", + "dirs-sys 0.4.1", +] + +[[package]] +name = "dirs" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3e8aa94d75141228480295a7d0e7feb620b1a5ad9f12bc40be62411e38cce4e" +dependencies = [ + "dirs-sys 0.5.0", ] [[package]] @@ -2003,10 +2012,22 @@ checksum = "520f05a5cbd335fae5a99ff7a6ab8627577660ee5cfd6a94a6a929b52ff0321c" dependencies = [ "libc", "option-ext", - "redox_users", + "redox_users 0.4.6", "windows-sys 0.48.0", ] +[[package]] +name = "dirs-sys" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e01a3366d27ee9890022452ee61b2b63a67e6f13f58900b651ff5665f0bb1fab" +dependencies = [ + "libc", + "option-ext", + "redox_users 0.5.2", + "windows-sys 0.59.0", +] + [[package]] name = "displaydoc" version = "0.2.5" @@ -2633,7 +2654,7 @@ dependencies = [ "criterion", "ctor", "dashmap", - "dirs", + "dirs 5.0.1", "dotenvy", "etcetera", "fs2", @@ -2735,6 +2756,7 @@ dependencies = [ "clap", "cliclack", "console", + "dirs 6.0.0", "dotenvy", "etcetera", "futures", @@ -5382,6 +5404,17 @@ dependencies = [ "thiserror 1.0.69", ] +[[package]] +name = "redox_users" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4e608c6638b9c18977b00b475ac1f28d14e84b27d8d42f70e0bf1e3dec127ac" +dependencies = [ + "getrandom 0.2.15", + "libredox", + "thiserror 2.0.12", +] + [[package]] name = "ref-cast" version = "1.0.24" @@ -6201,7 +6234,7 @@ version = "3.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8b1fdf65dd6331831494dd616b30351c38e96e45921a27745cf98490458b90bb" dependencies = [ - "dirs", + "dirs 5.0.1", ] [[package]] diff --git a/crates/goose-cli/Cargo.toml b/crates/goose-cli/Cargo.toml index 7404a2f84acc..9dd403b1df71 100644 --- a/crates/goose-cli/Cargo.toml +++ b/crates/goose-cli/Cargo.toml @@ -59,6 +59,7 @@ is-terminal = "0.4.16" anstream = "0.6.18" url = "2.5.7" open = "5.3.2" +dirs = "6.0.0" [target.'cfg(target_os = "windows")'.dependencies] winapi = { version = "0.3", features = ["wincred"] } diff --git a/crates/goose-cli/src/cli.rs b/crates/goose-cli/src/cli.rs index e0083d774200..f528fbad1243 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, @@ -829,6 +830,81 @@ 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 \ + @goose \"create a python script\" # using alias\n \ + @g \"quick question\" # short 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\n\n\ + With --default (anything typed that isn't a command goes to goose):\n \ + echo 'eval \"$(goose term init zsh --default)\"' >> ~/.zshrc" + )] + Init { + /// Shell type (bash, zsh, fish, powershell) + #[arg(value_enum)] + shell: Shell, + + /// Make goose the default handler for unknown commands + #[arg( + long = "default", + help = "Make goose the default handler for unknown commands", + long_help = "When enabled, anything you type that isn't a valid command will be sent to goose. Only supported for zsh and bash." + )] + default: bool, + }, + + /// 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 list files # using alias\n \ + @g why did that fail # short alias" + )] + Run { + /// The prompt to send to goose (multiple words allowed without quotes) + #[arg(required = true, num_args = 1..)] + prompt: Vec, + }, + + /// 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 +914,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 +958,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 +1472,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, default } => { + let shell_str = match shell { + Shell::Bash => "bash", + Shell::Zsh => "zsh", + Shell::Fish => "fish", + Shell::Powershell => "powershell", + }; + handle_term_init(shell_str, default).await?; + } + 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..eaf5a63318e2 --- /dev/null +++ b/crates/goose-cli/src/commands/term.rs @@ -0,0 +1,296 @@ +use anyhow::{anyhow, Result}; +use goose::config::paths::Paths; +use goose::session::SessionManager; +use std::fs; +use std::io::Write; +use std::path::PathBuf; + +use crate::session::{build_session, SessionBuilderConfig}; + +const TERMINAL_SESSION_PREFIX: &str = "term:"; + +async fn get_or_create_terminal_session(working_dir: PathBuf) -> Result { + let session_name = format!( + "{}{}", + TERMINAL_SESSION_PREFIX, + working_dir.to_string_lossy() + ); + + // Find existing session by name + let sessions = SessionManager::list_sessions().await?; + if let Some(session) = sessions.iter().find(|s| s.name == session_name) { + return Ok(session.id.clone()); + } + + // Create new session + let session = + SessionManager::create_session(working_dir, session_name.clone(), Default::default()) + .await?; + + SessionManager::update_session(&session.id) + .user_provided_name(session_name) + .apply() + .await?; + + Ok(session.id) +} + +/// Handle `goose term init ` - print shell initialization script +pub async fn handle_term_init(shell: &str, with_command_not_found: bool) -> Result<()> { + let working_dir = std::env::current_dir()?; + let session_id = get_or_create_terminal_session(working_dir).await?; + + let goose_bin = std::env::current_exe() + .map(|p| p.to_string_lossy().into_owned()) + .unwrap_or_else(|_| "goose".to_string()); + + let command_not_found_handler = if with_command_not_found { + match shell.to_lowercase().as_str() { + "bash" => format!( + r#" + +# Command not found handler - sends unknown commands to goose +command_not_found_handle() {{ + echo "🪿 Command '$1' not found. Asking goose..." + '{goose_bin}' term run "$@" + return 0 +}}"# + ), + "zsh" => format!( + r#" + +# Command not found handler - sends unknown commands to goose +command_not_found_handler() {{ + echo "🪿 Command '$1' not found. Asking goose..." + '{goose_bin}' term run "$@" + return 0 +}}"# + ), + _ => String::new(), + } + } else { + String::new() + }; + + let script = match shell.to_lowercase().as_str() { + "bash" => { + format!( + r#"export GOOSE_SESSION_ID="{session_id}" +alias gt='{goose_bin} term run' +alias @goose='{goose_bin} term run' +alias @g='{goose_bin} term run' + +# Log commands to goose (runs silently in background) +goose_preexec() {{ + [[ "$1" =~ ^goose\ term ]] && return + [[ "$1" =~ ^(gt|@goose|@g)($|[[: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{command_not_found_handler}"# + ) + } + "zsh" => { + format!( + r#"export GOOSE_SESSION_ID="{session_id}" +alias gt='{goose_bin} term run' +alias @goose='{goose_bin} term run' +alias @g='{goose_bin} term run' + +# Log commands to goose (runs silently in background) +goose_preexec() {{ + [[ "$1" =~ ^goose\ term ]] && return + [[ "$1" =~ ^(gt|@goose|@g)($|[[: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 + +# Add goose indicator to prompt +if [[ -z "$GOOSE_PROMPT_INSTALLED" ]]; then + export GOOSE_PROMPT_INSTALLED=1 + PROMPT='%F{{cyan}}🪿%f '$PROMPT +fi{command_not_found_handler}"# + ) + } + "fish" => { + format!( + r#"set -gx GOOSE_SESSION_ID "{session_id}" +function gt; {goose_bin} term run $argv; end +function @goose; {goose_bin} term run $argv; end +function @g; {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|@goose|@g)($|\s)' -- $argv[1]; and return + {goose_bin} term log "$argv[1]" 2>/dev/null & +end"# + ) + } + "powershell" | "pwsh" => { + format!( + r#"$env:GOOSE_SESSION_ID = "{session_id}" +function gt {{ & '{goose_bin}' term run @args }} +function @goose {{ & '{goose_bin}' term run @args }} +function @g {{ & '{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|@goose|@g)($|\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(()) +} + +fn shell_history_path(session_id: &str) -> Result { + let history_dir = Paths::config_dir().join("shell-history"); + fs::create_dir_all(&history_dir)?; + Ok(history_dir.join(format!("{}.txt", session_id))) +} + +fn append_shell_command(session_id: &str, command: &str) -> Result<()> { + let path = shell_history_path(session_id)?; + let mut file = fs::OpenOptions::new() + .create(true) + .append(true) + .open(path)?; + writeln!(file, "{}", command)?; + Ok(()) +} + +fn read_and_clear_shell_history(session_id: &str) -> Result> { + let path = shell_history_path(session_id)?; + + if !path.exists() { + return Ok(Vec::new()); + } + + let content = fs::read_to_string(&path)?; + let commands: Vec = content + .lines() + .filter(|line| !line.trim().is_empty()) + .map(|s| s.to_string()) + .collect(); + + fs::write(&path, "")?; + + Ok(commands) +} + +pub async fn handle_term_log(command: String) -> Result<()> { + let session_id = std::env::var("GOOSE_SESSION_ID").map_err(|_| { + anyhow!("GOOSE_SESSION_ID not set. Run 'eval \"$(goose term init )\"' first.") + })?; + + append_shell_command(&session_id, &command)?; + + Ok(()) +} + +pub async fn handle_term_run(prompt: Vec) -> Result<()> { + let prompt = prompt.join(" "); + let session_id = std::env::var("GOOSE_SESSION_ID").map_err(|_| { + anyhow!( + "GOOSE_SESSION_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 working_dir = std::env::current_dir()?; + + SessionManager::update_session(&session_id) + .working_dir(working_dir) + .apply() + .await?; + + let commands = read_and_clear_shell_history(&session_id)?; + 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<()> { + let session_id = match std::env::var("GOOSE_SESSION_ID") { + Ok(id) => id, + Err(_) => return Ok(()), + }; + + let session = SessionManager::get_session(&session_id, false).await.ok(); + let total_tokens = session.as_ref().and_then(|s| s.total_tokens).unwrap_or(0) as usize; + + let model_name = session + .as_ref() + .and_then(|s| s.model_config.as_ref().map(|mc| mc.model_name.clone())) + .map(|name| { + 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()); + + let context_limit = session + .as_ref() + .and_then(|s| s.model_config.as_ref().map(|mc| mc.context_limit())) + .unwrap_or(128_000); + + 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/documentation/docs/guides/terminal-integration.md b/documentation/docs/guides/terminal-integration.md new file mode 100644 index 000000000000..f3edc43279c5 --- /dev/null +++ b/documentation/docs/guides/terminal-integration.md @@ -0,0 +1,110 @@ +--- +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 +@goose "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. + +### Default Mode + +For **bash** and **zsh**, you can make goose the default handler for anything that isn't a valid command: + +```bash +# zsh +eval "$(goose term init zsh --default)" + +# bash +eval "$(goose term init bash --default)" +``` + +With this enabled, anything you type that isn't a command will be sent to goose: + +```bash +$ what files are in this directory? +🪿 Command 'what' not found. Asking goose... +``` + +Goose will interpret what you typed and help you accomplish the task. + +## Usage + +Once set up, your terminal session is linked to a goose session. All commands you run are logged to that session. + +To talk to goose about what you've been doing: + +```bash +@goose "why did that fail?" +``` + +You can also use `@g` as a shorter alias: + +```bash +@g "explain this error" +``` + +Both `@goose` and `@g` are aliases for `goose term run`. They open goose with your command history already loaded. + +## What Gets Logged + +Every command you type gets stored. Goose sees commands you ran since your last message to it. + +Commands starting with `goose term`, `@goose`, or `@g` 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_SESSION_ID` environment variable linking your terminal to a goose session +2. Creates `@goose` and `@g` aliases for quick access +3. Installs a preexec hook that calls `goose term log` for each command +4. Optionally installs a command-not-found handler (with `--default`) + +The hook runs `goose term log &` in the background, which appends to a local history file. When you run `@goose`, goose reads commands from this file that were logged since your last message. + +## Session Management + +Terminal sessions are tied to your working directory. If you `cd` to a different project, goose automatically creates or switches to a session for that directory. This keeps conversations organized by project.