add nushell terminal and completion support (#8628)

Signed-off-by: Can H. Tartanoglu <gpg@rotas.mozmail.com>
This commit is contained in:
Can H. Tartanoglu
2026-05-13 01:39:28 +02:00
committed by GitHub
parent ba60b597fa
commit 80cac3626f
7 changed files with 375 additions and 34 deletions
Generated
+11
View File
@@ -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",
+1
View File
@@ -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 }
+100 -7
View File
@@ -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<String>,
@@ -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"));
}
}
+106 -22
View File
@@ -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<String>,
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 <shell>)\"' first.")
anyhow!(
"AGENT_SESSION_ID not set. Initialize terminal integration with `goose term init <shell>` and reload your shell first."
)
})?;
let message = Message::new(
@@ -202,9 +253,8 @@ pub async fn handle_term_run(prompt: Vec<String>) -> 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 <shell>` 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"));
}
}
@@ -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<String> {
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();
@@ -104,7 +104,7 @@ Once installed, you can:
- Discover options without checking `--help`
**Arguments:**
- **`<SHELL>`**: The shell to generate completions for. Supported shells: `bash`, `elvish`, `fish`, `powershell`, `zsh`
- **`<SHELL>`**: 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`.
</TabItem>
<TabItem value="nu" label="Nushell">
```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")
```
</TabItem>
<TabItem value="powershell" label="PowerShell">
@@ -31,6 +31,16 @@ Add to `~/.config/fish/config.fish`:
goose term init fish | source
```
</TabItem>
<TabItem value="nu" label="Nushell">
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
```
</TabItem>
<TabItem value="powershell" label="PowerShell">
@@ -87,6 +97,15 @@ eval "$(goose term init bash --name my-project)"
goose term init fish --name my-project | source
```
</TabItem>
<TabItem value="nu" label="Nushell">
```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
```
</TabItem>
<TabItem value="powershell" label="PowerShell">
@@ -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.
<Tabs groupId="default-shells">
<TabItem value="zsh" label="zsh" default>
```bash
eval "$(goose term init zsh --default)"
```
</TabItem>
<TabItem value="bash" label="bash">
```bash
eval "$(goose term init bash --default)"
```
</TabItem>
<TabItem value="nu" label="Nushell">
```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
```
</TabItem>
</Tabs>
## 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
```
</TabItem>
<TabItem value="nu" label="Nushell">
```nu
$env.PROMPT_COMMAND = {|| $"(goose term info) (pwd)> " }
```
</TabItem>
<TabItem value="powershell" label="PowerShell">
@@ -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
```