diff --git a/NUSHELL.md b/NUSHELL.md new file mode 100644 index 00000000000..6d504a920ab --- /dev/null +++ b/NUSHELL.md @@ -0,0 +1,44 @@ +# Using Devbox with Nushell + +Devbox now supports [nushell](https://github.com/nushell/nushell) through the `--format` flag on the `shellenv` command. + +## Quick Start + +**Add this to `~/.config/nushell/env.nu`:** + +```nushell +devbox global shellenv --format nushell --preserve-path-stack -r + | lines + | parse "$env.{name} = \"{value}\"" + | where name != null + | transpose -r + | into record + | load-env +``` + +This is equivalent to bash's `eval "$(devbox global shellenv)"` and runs on every fresh shell start. + +--- + +## Global Configuration + +To use devbox global packages with nushell, you need to load the environment similar to how bash/zsh use `eval "$(devbox global shellenv)"`. + +### Dynamic loading with `load-env` - eval equivalent + +Add this to `~/.config/nushell/env.nu` to regenerate and load devbox environment fresh every time, just like bash's `eval`: + +```nushell +# Load devbox global environment dynamically (equivalent to bash eval) +devbox global shellenv --format nushell --preserve-path-stack -r + | lines + | parse "$env.{name} = \"{value}\"" + | where name != null + | transpose -r + | into record + | load-env +``` + +- `--format nushell` - Output in nushell syntax +- `--preserve-path-stack` - Maintain existing PATH order if devbox is already active +- `-r` (recompute) - Always recompute the environment, prevents "out of date" warnings diff --git a/internal/boxcli/global.go b/internal/boxcli/global.go index 9039d83d543..d1fb27f3ddd 100644 --- a/internal/boxcli/global.go +++ b/internal/boxcli/global.go @@ -112,10 +112,12 @@ func ensureGlobalEnvEnabled(cmd *cobra.Command, args []string) error { cmd.ErrOrStderr(), `devbox global is not activated. -Add the following line to your shell's rcfile (e.g., ~/.bashrc or ~/.zshrc) -and restart your shell to fix this: +Add the following line to your shell's rcfile and restart your shell: +For bash/zsh (~/.bashrc or ~/.zshrc): eval "$(devbox global shellenv)" + +For nushell: See NUSHELL.md for setup instructions `, ) } diff --git a/internal/boxcli/shellenv.go b/internal/boxcli/shellenv.go index 82cb85c5cf7..a6b61e2ae4e 100644 --- a/internal/boxcli/shellenv.go +++ b/internal/boxcli/shellenv.go @@ -24,6 +24,7 @@ type shellEnvCmdFlags struct { pure bool recomputeEnv bool runInitHook bool + format string } // shellenvFlagDefaults are the flag default values that differ @@ -46,7 +47,7 @@ func shellEnvCmd(defaults shellenvFlagDefaults) *cobra.Command { return err } fmt.Fprintln(cmd.OutOrStdout(), s) - if !strings.HasSuffix(os.Getenv("SHELL"), "fish") { + if flags.format != "nushell" && !strings.HasSuffix(os.Getenv("SHELL"), "fish") { fmt.Fprintln(cmd.OutOrStdout(), "hash -r") } return nil @@ -81,6 +82,11 @@ func shellEnvCmd(defaults shellenvFlagDefaults) *cobra.Command { "Recompute environment if needed", ) + command.Flags().StringVar( + &flags.format, "format", "bash", + "Output format for shell environment (nushell)", + ) + flags.config.register(command) flags.envFlag.register(command) @@ -115,6 +121,15 @@ func shellEnvFunc( } } + // Convert format string to ShellFormat type + var shellFormat devopt.ShellFormat + switch flags.format { + case "nushell": + shellFormat = devopt.ShellFormatNushell + default: + shellFormat = devopt.ShellFormatBash + } + envStr, err := box.EnvExports(ctx, devopt.EnvExportsOpts{ EnvOptions: devopt.EnvOptions{ Hooks: devopt.LifecycleHooks{ @@ -136,6 +151,7 @@ func shellEnvFunc( }, NoRefreshAlias: flags.noRefreshAlias, RunHooks: flags.runInitHook, + ShellFormat: shellFormat, }) if err != nil { return "", err diff --git a/internal/devbox/devbox.go b/internal/devbox/devbox.go index d65ba219352..994318c837a 100644 --- a/internal/devbox/devbox.go +++ b/internal/devbox/devbox.go @@ -367,7 +367,13 @@ func (d *Devbox) EnvExports(ctx context.Context, opts devopt.EnvExportsOpts) (st return "", err } - envStr := exportify(envs) + // Use the appropriate export format based on shell type + var envStr string + if opts.ShellFormat == devopt.ShellFormatNushell { + envStr = exportifyNushell(envs) + } else { + envStr = exportify(envs) + } if opts.RunHooks { hooksStr := ". \"" + shellgen.ScriptPath(d.ProjectDir(), shellgen.HooksFilename) + "\"" @@ -375,7 +381,7 @@ func (d *Devbox) EnvExports(ctx context.Context, opts devopt.EnvExportsOpts) (st } if !opts.NoRefreshAlias { - envStr += "\n" + d.refreshAlias() + envStr += "\n" + d.refreshAliasForShell(string(opts.ShellFormat)) } return envStr, nil diff --git a/internal/devbox/devopt/devboxopts.go b/internal/devbox/devopt/devboxopts.go index 6ae164566c8..9afeaccc4c1 100644 --- a/internal/devbox/devopt/devboxopts.go +++ b/internal/devbox/devopt/devboxopts.go @@ -69,10 +69,18 @@ type UpdateOpts struct { IgnoreMissingPackages bool } +type ShellFormat string + +const ( + ShellFormatBash ShellFormat = "bash" + ShellFormatNushell ShellFormat = "nushell" +) + type EnvExportsOpts struct { EnvOptions EnvOptions NoRefreshAlias bool RunHooks bool + ShellFormat ShellFormat } // EnvOptions configure the Devbox Environment in the `computeEnv` function. diff --git a/internal/devbox/envvars.go b/internal/devbox/envvars.go index 996454ea683..ec8231e19ab 100644 --- a/internal/devbox/envvars.go +++ b/internal/devbox/envvars.go @@ -60,6 +60,57 @@ func exportify(vars map[string]string) string { return strings.TrimSpace(strb.String()) } +// exportifyNushell formats vars as nushell environment variable assignments. +// Each line is of the form `$env.KEY = "value"` with special characters escaped. +func exportifyNushell(vars map[string]string) string { + // Nushell protected environment variables that cannot be set manually + // See: https://www.nushell.sh/book/environment.html#automatic-environment-variables + protectedVars := map[string]bool{ + "CURRENT_FILE": true, + "FILE_PWD": true, + "LAST_EXIT_CODE": true, + "CMD_DURATION_MS": true, + "NU_VERSION": true, + "PWD": true, // Nushell manages this automatically + } + + keys := make([]string, len(vars)) + i := 0 + for k := range vars { + keys[i] = k + i++ + } + slices.Sort(keys) // for reproducibility + + strb := strings.Builder{} + for _, key := range keys { + // Skip bash functions for nushell + if strings.HasPrefix(key, "BASH_FUNC_") && strings.HasSuffix(key, "%%") { + continue + } + + // Skip nushell protected environment variables + if protectedVars[key] { + continue + } + + // Nushell environment variable syntax: $env.KEY = "value" + strb.WriteString("$env.") + strb.WriteString(key) + strb.WriteString(` = "`) + for _, r := range vars[key] { + switch r { + // Escape special characters for nushell double-quoted strings + case '"', '\\': + strb.WriteRune('\\') + } + strb.WriteRune(r) + } + strb.WriteString("\"\n") + } + return strings.TrimSpace(strb.String()) +} + // addEnvIfNotPreviouslySetByDevbox adds the key-value pairs from new to existing, // but only if the key was not previously set by devbox // Caveat, this won't mark the values as set by devbox automatically. Instead, diff --git a/internal/devbox/refresh.go b/internal/devbox/refresh.go index 9f9f650670b..340fc269744 100644 --- a/internal/devbox/refresh.go +++ b/internal/devbox/refresh.go @@ -52,6 +52,21 @@ func (d *Devbox) refreshCmd() string { return fmt.Sprintf(`eval "$(devbox %s)" && hash -r`, devboxCmd) } +func (d *Devbox) refreshCmdForShell(format string) string { + devboxCmd := fmt.Sprintf("shellenv --preserve-path-stack -c %q", d.projectDir) + if d.isGlobal() { + devboxCmd = "global shellenv --preserve-path-stack -r --format " + format + } else { + devboxCmd = fmt.Sprintf("shellenv --preserve-path-stack -c %q --format %s", d.projectDir, format) + } + + if format == "nushell" { + // Nushell doesn't have eval; use overlay or source with temporary file + return fmt.Sprintf(`devbox %s | save -f ~/.cache/devbox-env.nu; source ~/.cache/devbox-env.nu`, devboxCmd) + } + return fmt.Sprintf(`eval "$(devbox %s)" && hash -r`, devboxCmd) +} + func (d *Devbox) refreshAlias() string { if isFishShell() { return fmt.Sprintf( @@ -74,3 +89,20 @@ fi`, d.refreshCmd(), ) } + +func (d *Devbox) refreshAliasForShell(format string) string { + // For nushell format, provide instructions as a comment since aliases with pipes are complex + if format == "nushell" { + devboxCmd := "global shellenv --preserve-path-stack -r --format nushell" + if !d.isGlobal() { + devboxCmd = fmt.Sprintf("shellenv --preserve-path-stack -c %q --format nushell", d.projectDir) + } + return fmt.Sprintf( + `# To refresh your devbox environment in nushell, run: +# devbox %s | save -f ~/.cache/devbox-env.nu; source ~/.cache/devbox-env.nu`, + devboxCmd, + ) + } + // Otherwise use the original refreshAlias function + return d.refreshAlias() +}