diff --git a/Cargo.lock b/Cargo.lock index 0fd5ecea08..a015086eb6 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1870,6 +1870,16 @@ dependencies = [ "clap", ] +[[package]] +name = "clap_complete_nushell" +version = "4.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fbb9e9715d29a754b468591be588f6b926f5b0a1eb6a8b62acabeb66ff84d897" +dependencies = [ + "clap", + "clap_complete", +] + [[package]] name = "clap_derive" version = "4.6.1" @@ -4500,6 +4510,7 @@ dependencies = [ "chrono", "clap", "clap_complete", + "clap_complete_nushell", "clap_mangen", "cliclack", "comfy-table", diff --git a/crates/goose-cli/Cargo.toml b/crates/goose-cli/Cargo.toml index bea6b8cd50..71f680ac2c 100644 --- a/crates/goose-cli/Cargo.toml +++ b/crates/goose-cli/Cargo.toml @@ -64,6 +64,7 @@ comfy-table = "7.2.2" sha2 = { workspace = true } sigstore-verify = { version = "=0.6.5", default-features = false } axum.workspace = true +clap_complete_nushell = "4.6.0" [target.'cfg(target_os = "windows")'.dependencies] winapi = { workspace = true } diff --git a/crates/goose-cli/src/cli.rs b/crates/goose-cli/src/cli.rs index 5d2ad9324c..b8a34b30e4 100644 --- a/crates/goose-cli/src/cli.rs +++ b/crates/goose-cli/src/cli.rs @@ -1,6 +1,7 @@ use anyhow::Result; use clap::{Args, CommandFactory, Parser, Subcommand}; use clap_complete::{generate, Shell as ClapShell}; +use clap_complete_nushell::Nushell as ClapNushell; use goose::agents::GoosePlatform; use goose::builtin_extension::register_builtin_extensions; use goose::config::{Config, GooseMode}; @@ -934,7 +935,8 @@ enum Command { 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\ + eval \"$(goose term init zsh)\" # zsh/bash\n \ + let init = ($nu.cache-dir | path join \"goose-term-init.nu\"); ^goose term init nu | save --force $init; source $init\n\n\ Usage:\n \ goose term run \"list files in this directory\"\n \ @goose \"create a python script\" # using alias\n \ @@ -953,10 +955,12 @@ enum Command { }, /// Generate completions for various shells - #[command(about = "Generate the autocompletion script for the specified shell")] + #[command( + about = "Generate the autocompletion script or Nushell module for the specified shell" + )] Completion { #[arg(value_enum)] - shell: ClapShell, + shell: CompletionShell, #[arg(long, default_value = "goose", help = "Provide a custom binary name")] bin_name: String, @@ -1016,11 +1020,16 @@ enum TermCommand { Setup:\n \ echo 'eval \"$(goose term init zsh)\"' >> ~/.zshrc\n \ source ~/.zshrc\n\n\ + Nushell:\n \ + let init = ($nu.cache-dir | path join \"goose-term-init.nu\")\n \ + ^goose term init nu | save --force $init\n \ + source $init\n\n\ With --default (anything typed that isn't a command goes to goose):\n \ - echo 'eval \"$(goose term init zsh --default)\"' >> ~/.zshrc" + echo 'eval \"$(goose term init zsh --default)\"' >> ~/.zshrc\n \ + ^goose term init nu --default | save --force $init" )] Init { - /// Shell type (bash, zsh, fish, powershell) + /// Shell type (bash, zsh, fish, nu, powershell) #[arg(value_enum)] shell: Shell, @@ -1031,7 +1040,7 @@ enum TermCommand { #[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." + long_help = "When enabled, anything you type that isn't a valid command will be sent to goose. Supported for zsh, bash, and nu." )] default: bool, }, @@ -1074,6 +1083,31 @@ enum CliProviderVariant { Ollama, } +#[derive(clap::ValueEnum, Clone, Copy, Debug, PartialEq, Eq)] +enum CompletionShell { + Bash, + Elvish, + Fish, + #[value(alias = "pwsh")] + Powershell, + #[value(alias = "nushell")] + Nu, + Zsh, +} + +impl CompletionShell { + fn generate(self, cmd: &mut clap::Command, bin_name: &str, writer: &mut dyn std::io::Write) { + match self { + CompletionShell::Bash => generate(ClapShell::Bash, cmd, bin_name, writer), + CompletionShell::Elvish => generate(ClapShell::Elvish, cmd, bin_name, writer), + CompletionShell::Fish => generate(ClapShell::Fish, cmd, bin_name, writer), + CompletionShell::Powershell => generate(ClapShell::PowerShell, cmd, bin_name, writer), + CompletionShell::Nu => generate(ClapNushell, cmd, bin_name, writer), + CompletionShell::Zsh => generate(ClapShell::Zsh, cmd, bin_name, writer), + } + } +} + #[derive(Debug)] pub struct InputConfig { pub contents: Option, @@ -1846,7 +1880,7 @@ pub async fn cli() -> anyhow::Result<()> { match cli.command { Some(Command::Completion { shell, bin_name }) => { let mut cmd = Cli::command(); - generate(shell, &mut cmd, bin_name, &mut std::io::stdout()); + shell.generate(&mut cmd, &bin_name, &mut std::io::stdout()); Ok(()) } Some(Command::Configure {}) => handle_configure().await, @@ -1939,3 +1973,62 @@ pub async fn cli() -> anyhow::Result<()> { None => handle_default_session().await, } } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn completion_command_accepts_nushell_alias() { + let cli = Cli::try_parse_from(["goose", "completion", "nushell"]).expect("parse failed"); + + match cli.command { + Some(Command::Completion { + shell: CompletionShell::Nu, + .. + }) => {} + _ => panic!("expected nu completion shell"), + } + } + + #[test] + fn nushell_completion_generation_emits_module() { + let mut cmd = Cli::command(); + let mut buffer = Vec::new(); + + CompletionShell::Nu.generate(&mut cmd, "goose", &mut buffer); + + let script = String::from_utf8(buffer).expect("utf8"); + assert!(script.contains("module completions")); + assert!(script.contains("export extern goose")); + assert!(script.contains("export use completions *")); + } + + #[test] + fn term_init_help_mentions_nushell() { + let mut cmd = Cli::command(); + let term = cmd.find_subcommand_mut("term").expect("term command"); + let init = term.find_subcommand_mut("init").expect("init command"); + let mut buffer = Vec::new(); + + init.write_long_help(&mut buffer).expect("write help"); + + let help = String::from_utf8(buffer).expect("utf8"); + assert!(help.contains("goose term init nu")); + assert!(help.contains("Supported for zsh, bash, and nu")); + } + + #[test] + fn completion_help_lists_nu() { + let mut cmd = Cli::command(); + let completion = cmd + .find_subcommand_mut("completion") + .expect("completion command"); + let mut buffer = Vec::new(); + + completion.write_long_help(&mut buffer).expect("write help"); + + let help = String::from_utf8(buffer).expect("utf8"); + assert!(help.contains("nu")); + } +} diff --git a/crates/goose-cli/src/commands/term.rs b/crates/goose-cli/src/commands/term.rs index dd539d5f24..8596eeb782 100644 --- a/crates/goose-cli/src/commands/term.rs +++ b/crates/goose-cli/src/commands/term.rs @@ -9,11 +9,13 @@ use crate::session::{build_session, SessionBuilderConfig}; use clap::ValueEnum; -#[derive(ValueEnum, Clone, Debug)] +#[derive(ValueEnum, Clone, Copy, Debug, PartialEq, Eq)] pub enum Shell { Bash, Zsh, Fish, + #[value(alias = "nushell")] + Nu, #[value(alias = "pwsh")] Powershell, } @@ -29,6 +31,7 @@ impl Shell { Shell::Bash => &BASH_CONFIG, Shell::Zsh => &ZSH_CONFIG, Shell::Fish => &FISH_CONFIG, + Shell::Nu => &NU_CONFIG, Shell::Powershell => &POWERSHELL_CONFIG, } } @@ -97,6 +100,42 @@ end"#, command_not_found: None, }; +static NU_CONFIG: ShellConfig = ShellConfig { + script_template: r#"$env.AGENT_SESSION_ID = "{session_id}" +def --wrapped @goose [...args] { run-external "{goose_bin}" "term" "run" ...$args } +def --wrapped @g [...args] { run-external "{goose_bin}" "term" "run" ...$args } + +if (($env | get -o GOOSE_NU_PREEXEC_INSTALLED | default false) != true) { + $env.GOOSE_NU_PREEXEC_INSTALLED = true + $env.config.hooks.pre_execution = ( + $env.config.hooks.pre_execution + | append {|| + let line = (commandline | str trim) + if ($line | is-empty) { + return + } + if ($line =~ '^goose term(\s|$)') { + return + } + if ($line =~ '^(@goose|@g)(\s|$)') { + return + } + job spawn { run-external "{goose_bin}" "term" "log" $line | complete | ignore } | ignore + } + ) +} +{command_not_found_handler}"#, + command_not_found: Some( + r#" +$env.config.hooks.command_not_found = {|command_name| + let prompt = (try { commandline | str trim } catch { $command_name }) + print $"🪿 Command '($command_name)' not found. Asking goose..." + run-external "{goose_bin}" "term" "run" $prompt | complete | ignore + null +}"#, + ), +}; + static POWERSHELL_CONFIG: ShellConfig = ShellConfig { script_template: r#"$env:AGENT_SESSION_ID = "{session_id}" function @goose {{ & '{goose_bin}' term run @args }} @@ -113,12 +152,34 @@ Set-PSReadLineKeyHandler -Chord Enter -ScriptBlock {{ command_not_found: None, }; +fn render_term_init_script( + shell: Shell, + session_id: &str, + goose_bin: &str, + with_command_not_found: bool, +) -> String { + let config = shell.config(); + let command_not_found_handler = if with_command_not_found { + config + .command_not_found + .map(|handler| handler.replace("{goose_bin}", goose_bin)) + .unwrap_or_default() + } else { + String::new() + }; + + config + .script_template + .replace("{session_id}", session_id) + .replace("{goose_bin}", goose_bin) + .replace("{command_not_found_handler}", &command_not_found_handler) +} + pub async fn handle_term_init( shell: Shell, name: Option, with_command_not_found: bool, ) -> Result<()> { - let config = shell.config(); let session_manager = SessionManager::instance(); let working_dir = std::env::current_dir()?; @@ -159,28 +220,18 @@ pub async fn handle_term_init( .map(|p| p.to_string_lossy().into_owned()) .unwrap_or_else(|_| "goose".to_string()); - let command_not_found_handler = if with_command_not_found { - config - .command_not_found - .map(|s| s.replace("{goose_bin}", &goose_bin)) - .unwrap_or_default() - } else { - String::new() - }; - - let script = config - .script_template - .replace("{session_id}", &session.id) - .replace("{goose_bin}", &goose_bin) - .replace("{command_not_found_handler}", &command_not_found_handler); - - println!("{}", script); + println!( + "{}", + render_term_init_script(shell, &session.id, &goose_bin, with_command_not_found) + ); Ok(()) } pub async fn handle_term_log(command: String) -> Result<()> { let session_id = std::env::var("AGENT_SESSION_ID").map_err(|_| { - anyhow!("AGENT_SESSION_ID not set. Run 'eval \"$(goose term init )\"' first.") + anyhow!( + "AGENT_SESSION_ID not set. Initialize terminal integration with `goose term init ` and reload your shell first." + ) })?; let message = Message::new( @@ -202,9 +253,8 @@ pub async fn handle_term_run(prompt: Vec) -> Result<()> { let session_id = std::env::var("AGENT_SESSION_ID").map_err(|_| { anyhow!( "AGENT_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" + Initialize terminal integration with `goose term init ` in your shell profile, \ + then restart or reload that shell." ) })?; @@ -317,3 +367,37 @@ pub async fn handle_term_info() -> Result<()> { Ok(()) } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn render_term_init_script_includes_nushell_hooks() { + let script = render_term_init_script(Shell::Nu, "session-123", "/tmp/goose", false); + + assert!(script.contains("$env.AGENT_SESSION_ID = \"session-123\"")); + assert!(script.contains("def --wrapped @goose [...args]")); + assert!(script.contains("def --wrapped @g [...args]")); + assert!(script.contains("GOOSE_NU_PREEXEC_INSTALLED")); + assert!(script.contains("$env.config.hooks.pre_execution")); + assert!(script.contains("job spawn { run-external \"/tmp/goose\" \"term\" \"log\" $line | complete | ignore } | ignore")); + assert!(!script.contains("command_not_found = {|command_name|")); + } + + #[test] + fn render_term_init_script_includes_nushell_default_handler() { + let script = render_term_init_script(Shell::Nu, "session-123", "/tmp/goose", true); + + assert!(script.contains("$env.config.hooks.command_not_found = {|command_name|")); + assert!(script + .contains("run-external \"/tmp/goose\" \"term\" \"run\" $prompt | complete | ignore")); + } + + #[test] + fn render_term_init_script_skips_unsupported_default_handler() { + let script = render_term_init_script(Shell::Fish, "session-123", "/tmp/goose", true); + + assert!(!script.contains("command_not_found")); + } +} diff --git a/crates/goose/src/agents/platform_extensions/developer/shell.rs b/crates/goose/src/agents/platform_extensions/developer/shell.rs index 8a229fd6a8..bc8f4e6d5d 100644 --- a/crates/goose/src/agents/platform_extensions/developer/shell.rs +++ b/crates/goose/src/agents/platform_extensions/developer/shell.rs @@ -47,6 +47,42 @@ fn flatpak_spawn_process() -> std::process::Command { command } +#[cfg(not(windows))] +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum UnixShellFlavor { + Posix, + Nushell, +} + +#[cfg(not(windows))] +fn unix_shell_flavor(shell: &str) -> UnixShellFlavor { + let name = std::path::Path::new(shell) + .file_stem() + .and_then(|stem| stem.to_str()) + .unwrap_or(shell) + .to_ascii_lowercase(); + + match name.as_str() { + "nu" | "nushell" => UnixShellFlavor::Nushell, + _ => UnixShellFlavor::Posix, + } +} + +#[cfg(not(windows))] +fn unix_login_shell_command_args(shell: &str) -> [&'static str; 4] { + let probe = match unix_shell_flavor(shell) { + UnixShellFlavor::Nushell => "print ($env.PATH | str join (char esep))", + UnixShellFlavor::Posix => "echo $PATH", + }; + + ["-l", "-i", "-c", probe] +} + +#[cfg(not(windows))] +fn unix_shell_command_args(command_line: &str) -> [&str; 2] { + ["-c", command_line] +} + /// Resolve the preferred Unix shell for command execution, respecting GOOSE_SHELL. /// /// Auto-detected shells are returned as basenames (e.g. `"bash"`) so that @@ -169,15 +205,16 @@ fn resolve_login_shell_path() -> Option { use process_wrap::std::{CommandWrap, ProcessSession}; let shell = unix_shell(); + let login_args = unix_login_shell_command_args(&shell); // Build the command, varying only the flatpak vs direct invocation. let mut cmd = if is_flatpak() { let mut c = flatpak_spawn_process(); - c.args([&shell, "-l", "-i", "-c", "echo $PATH"]); + c.arg(&shell).args(login_args); CommandWrap::from(c) } else { let mut c = std::process::Command::new(&shell); - c.args(["-l", "-i", "-c", "echo $PATH"]); + c.args(login_args); CommandWrap::from(c) }; @@ -597,11 +634,13 @@ fn build_shell_command( if let Some(path) = login_path { command.arg(format!("--env=PATH={}", path)); } - command.arg(&shell).arg("-c").arg(command_line); + command + .arg(&shell) + .args(unix_shell_command_args(command_line)); command } else { let mut command = tokio::process::Command::new(shell); - command.arg("-c").arg(command_line); + command.args(unix_shell_command_args(command_line)); if let Some(path) = working_dir { command.current_dir(path); } @@ -812,6 +851,37 @@ mod tests { assert_eq!(observed, expected); } + #[cfg(not(windows))] + #[test] + fn unix_shell_flavor_detects_nushell_names() { + assert_eq!(unix_shell_flavor("nu"), UnixShellFlavor::Nushell); + assert_eq!(unix_shell_flavor("nushell"), UnixShellFlavor::Nushell); + assert_eq!( + unix_shell_flavor("/etc/profiles/per-user/can/bin/nu"), + UnixShellFlavor::Nushell + ); + assert_eq!(unix_shell_flavor("/bin/bash"), UnixShellFlavor::Posix); + } + + #[cfg(not(windows))] + #[test] + fn unix_login_shell_command_args_use_nushell_probe() { + assert_eq!( + unix_login_shell_command_args("nu"), + ["-l", "-i", "-c", "print ($env.PATH | str join (char esep))"] + ); + assert_eq!( + unix_login_shell_command_args("/bin/bash"), + ["-l", "-i", "-c", "echo $PATH"] + ); + } + + #[cfg(not(windows))] + #[test] + fn unix_shell_command_args_wrap_commands_for_execution() { + assert_eq!(unix_shell_command_args("ls -la"), ["-c", "ls -la"]); + } + #[test] fn render_output_returns_full_output_when_under_limit() { let dir = tempfile::tempdir().unwrap(); diff --git a/documentation/docs/guides/goose-cli-commands.md b/documentation/docs/guides/goose-cli-commands.md index 93e0646ea5..365899d02c 100644 --- a/documentation/docs/guides/goose-cli-commands.md +++ b/documentation/docs/guides/goose-cli-commands.md @@ -104,7 +104,7 @@ Once installed, you can: - Discover options without checking `--help` **Arguments:** -- **``**: The shell to generate completions for. Supported shells: `bash`, `elvish`, `fish`, `powershell`, `zsh` +- **``**: The shell to generate completions for. Supported shells: `bash`, `elvish`, `fish`, `nu`, `powershell`, `zsh` **Usage:** ```bash @@ -112,6 +112,7 @@ Once installed, you can: goose completion bash goose completion zsh goose completion fish +goose completion nu ``` **Installation by Shell:** @@ -153,6 +154,20 @@ goose completion fish > ~/.config/fish/completions/goose.fish Then restart your terminal or run `exec fish`. + + + +```nu +let autoload_dir = ($nu.user-autoload-dirs | first) +mkdir $autoload_dir +goose completion nu | save --force ($autoload_dir | path join "goose.nu") +``` + +Then restart Nushell or run: +```nu +source (($nu.user-autoload-dirs | first) | path join "goose.nu") +``` + diff --git a/documentation/docs/guides/terminal-integration.md b/documentation/docs/guides/terminal-integration.md index 33da7d655f..ad189f27f6 100644 --- a/documentation/docs/guides/terminal-integration.md +++ b/documentation/docs/guides/terminal-integration.md @@ -31,6 +31,16 @@ Add to `~/.config/fish/config.fish`: goose term init fish | source ``` + + + +Add to `~/.config/nushell/config.nu`: +```nu +let goose_term_init = ($nu.cache-dir | path join "goose-term-init.nu") +^goose term init nu | save --force $goose_term_init +source $goose_term_init +``` + @@ -87,6 +97,15 @@ eval "$(goose term init bash --name my-project)" goose term init fish --name my-project | source ``` + + + +```nu +let goose_term_init = ($nu.cache-dir | path join "goose-term-init.nu") +^goose term init nu --name my-project | save --force $goose_term_init +source $goose_term_init +``` + @@ -110,6 +129,36 @@ eval "$(goose term init zsh --name auth-bug)" # Continues the same conversation with context ``` +## Default Handler + +Use `--default` if you want goose to answer commands your shell cannot resolve. + + + + +```bash +eval "$(goose term init zsh --default)" +``` + + + + +```bash +eval "$(goose term init bash --default)" +``` + + + + +```nu +let goose_term_init = ($nu.cache-dir | path join "goose-term-init.nu") +^goose term init nu --default | save --force $goose_term_init +source $goose_term_init +``` + + + + ## Show Context Status in Your Prompt Add `goose term info` to your prompt to see how much context you've used and which model is active during a terminal goose session. @@ -138,6 +187,13 @@ function fish_prompt end ``` + + + +```nu +$env.PROMPT_COMMAND = {|| $"(goose term info) (pwd)> " } +``` + @@ -170,6 +226,11 @@ You can also check the id of the goose session in your current terminal: echo $AGENT_SESSION_ID # Should show something like: 20251209_151730 ``` +```nu +# Nushell +$env.AGENT_SESSION_ID +# Should show something like: 20251209_151730 +``` To share context across terminal windows, use a [named session](#named-sessions) instead. **Session getting too full** (prompt shows `●●●●●`): @@ -178,3 +239,9 @@ If goose's responses are getting slow or hitting context limits, start a fresh g # Start a new goose session in the same shell eval "$(goose term init zsh)" ``` +```nu +# Nushell +let goose_term_init = ($nu.cache-dir | path join "goose-term-init.nu") +^goose term init nu | save --force $goose_term_init +source $goose_term_init +```