I replaced Fish with Nushell as my login shell on macOS. Fish was already comfortable. It had good completions, vi keybindings, Starship, and the abbreviations I use all day. Nu had to keep that feel while giving me a better way to work with command output.
The switch held up because Nu's data model kept showing up in ordinary commands and small scripts. My login shell now points to Nu, while Fish remains installed and even supplies a few completions that Nu cannot generate on its own.
Fish set a high bar
Fish already gave me the parts of an interactive shell that I care about. Its autosuggestions, syntax highlighting, and completions (opens in a new tab) (відкриється в новій вкладці) worked well, and my configuration added Starship, Atuin history, Zoxide, Mise, and custom keybindings. I cached generated integrations to keep startup fast.
My Fish config also routed listings through eza: ls for icons and a useful
sort order, and ll for a Git-aware long view. Those aliases gave me readable
output, but filtering it meant parsing text or asking another command for a
machine-readable format.
My Nushell configuration repository dates to August 12, 2025. It stayed an
experiment until August 3, 2026, when I changed the account login shell and
committed a larger pass titled make Nushell viable as a daily shell. The next
few days went into completion gaps, startup caches, prompt behavior, and small
compatibility fixes.
Rows I can keep working with
Nu commands pass typed values through pipelines (opens in a new tab) (відкриється в новій вкладці). Those values can include records, lists, tables, dates, durations, and file sizes. External commands still emit text or bytes, so familiar Unix pipelines continue to work. I can keep working with the structure when a Nu command knows the shape of its output.
ls returns a table. I can filter directories, sort on the modified column,
and choose the columns I want without parsing the display:
ls
| where type == dir
| sort-by modified --reverse
| select name modifiedMy Nu config deliberately keeps the built-in ls and open. ls stays Nu's
default. I can still run eza directly, and ll uses it when I want icons and a
Git-aware view. I am still deciding whether the default ls is enough for daily
use. When I need to keep working with the result, I use its structured rows.
open package.json reads JSON into a record, and the rest of the pipeline works
on fields:
open package.json
| get scripts
| transpose name command
| sort-by nameNu has built-in converters for formats including JSON, TOML, YAML, and CSV.
Each converter expects a value shape compatible with its target format. For the
formats I use, moving from from json to to toml feels like choosing an output
type instead of assembling another text pipeline.
A CLI table becomes Codex configuration
I was already trying Nushell as my daily shell. The codex features list
example made me appreciate its data model enough to stick with Nushell. Codex
prints its feature list as aligned text. detect columns is the bridge for
output like that:
^codex features list
| detect columns --guess --no-headers
| rename name stage enabled
| update enabled { into bool }
| where stage in ['under development' stable] and enabled == false
| reduce --fold {} {|feature, config|
$config | upsert $feature.name true
}
| to toml
| pbcopydetect columns turns the text into rows, rename gives the columns useful
names, and update converts enabled from text to booleans. where keeps
disabled features in the two stages I asked for. reduce builds one record
whose keys are feature names, and to toml produces lines I can paste under
[features] in ~/.codex/config.toml. pbcopy puts that final string on the
macOS clipboard.
detect columns guesses structure from layout (opens in a new tab) (відкриється в новій вкладці),
so I treat it as an adapter for stable text output. If a CLI offers JSON, I use
that instead. The rest of the pipeline can then keep the same records and
filters without depending on column spacing.
Parallel updates without output soup
My update-tools function updates Homebrew, Mise, Claude, Codex, and my global
skills from one command:
export def update-tools [] {
let jobs = [
{
name: Homebrew
commands: [
{|| brew upgrade -y }
{|| brew cleanup }
]
}
{
name: Mise
commands: [
{|| mise upgrade }
]
}
{
name: Claude
commands: [
{|| claude update }
]
}
{
name: Codex
commands: [
{|| codex update }
]
}
{
name: Skills
commands: [
{|| skills update -g -y }
]
}
]
$jobs | par-each --threads 5 { |job|
let results = (
$job.commands
| each { |command|
with-env {
CLICOLOR_FORCE: "1"
FORCE_COLOR: "1"
HOMEBREW_COLOR: "1"
} {
do $command o+e>| complete
}
}
)
let output = (
$results
| get stdout
| str join
| str trim
)
let succeeded = (
$results
| all { |result| $result.exit_code == 0 }
)
let heading = if $succeeded {
$"(ansi green_bold)✓ ($job.name)(ansi reset)"
} else {
$"(ansi red_bold)✗ ($job.name)(ansi reset)"
}
print $"\n($heading)\n($output)"
} | ignore
}The outer list contains records. Each record has a display name and one or more
command closures. par-each --threads 5 runs the five job records in parallel,
while the inner each keeps commands inside one job sequential. Homebrew still
runs cleanup after its upgrade. Mise, Claude, Codex, and Skills do not wait for
that pair.
o+e>| complete combines a command's output streams, runs the closure, and
returns structured results with stdout and exit_code. The function joins
each job's output, checks every exit code, and prints one green or red block for
that job. Blocks can appear in completion order because par-each does not keep
input order unless I ask it to.
Fish can run background jobs with & and wait for them. Nu makes the bookkeeping
fit the rest of this function: jobs are records, commands are closures, and
results have fields. That gives me parallel updates without one long mise upgrade && claude update && codex update chain or five tools writing into the
same unreadable stream.
The rest of my terminal came with me
I still use the same ~/.config/starship.toml. Fish loaded generated output from
starship init fish; Nu loads a cached starship init nu. My $all prompt,
colors, Git status, and command duration stayed intact. I kept Atuin, Zoxide,
Mise, and Carapace through their Nu integrations too.
Completions needed more work. I use native Nu completions for Git and Claude, Carapace as a broad fallback, and small targeted adapters for gaps. Codex and Herdr can generate Fish completions but not native Nu completions in the versions I use, so my Nu completer routes those files through Fish. Keeping Fish installed is useful even after changing the login shell.
I write .nu files in Neovim. Enabling the
LazyVim Nushell extra (opens in a new tab) (відкриється в новій вкладці)
through :LazyExtras configured the Nushell language server and installed the
nu Treesitter parser. The LSP has helped me write functions and catch mistakes
in closures. Setup took one extra toggle in a Neovim environment I already used.
I still use jq, yq, rg, and fd
Nushell replaced Fish as my daily shell. I kept the focused command-line tools I
already trusted. I use jq for JSON and yq for YAML when their query languages
are the clearest option or when a command needs to run outside Nu. I use rg to
search file contents and fd to find files. bat still gives me a
display-oriented view for files.
I reach for Nu when the shell itself knows the data shape: ls, open, dates,
file sizes, or a CLI result I can parse into stable columns. It also fits small
scripts where I need to filter records, run work in parallel, inspect exit
codes, and serialize the result.
Nushell is not POSIX-compatible, and its
default-shell guide (opens in a new tab) (відкриється в новій вкладці)
warns that some programs assume a POSIX login shell. External completion
coverage varies. detect columns remains a guess, and I had to build caches and
adapters around a few tools. Fish stays one command away if I hit a script that
expects it.
I can inspect, filter, and serialize directory listings or CLI tables without
flattening them into text, and update-tools handles the parallel jobs. That is
enough to keep Nu as my login shell for now. We'll see how it goes.
Sources
- Nushell: pipelines (opens in a new tab) (відкриється в новій вкладці)
- Nushell: types of data (opens in a new tab) (відкриється в новій вкладці)
- Nushell: loading data (opens in a new tab) (відкриється в новій вкладці)
-
Nushell:
ls(opens in a new tab) (відкриється в новій вкладці) -
Nushell:
detect columns(opens in a new tab) (відкриється в новій вкладці) -
Nushell:
par-each(opens in a new tab) (відкриється в новій вкладці) -
Nushell:
complete(opens in a new tab) (відкриється в новій вкладці) -
Nushell:
to toml(opens in a new tab) (відкриється в новій вкладці) - Fish tutorial (opens in a new tab) (відкриється в новій вкладці)
- LazyVim: Nushell extra (opens in a new tab) (відкриється в новій вкладці)