| name | nushell-style |
| description | This skill should be used when writing, editing, reviewing, or debugging Nushell (.nu) files. Covers opinionated pipeline composition, command choices (where vs filter, match vs if/else, get --optional), formatting conventions (Topiary), type signatures, module structure, testing with nutest (unit tests, snapshot tests, @example attributes, coverage), NUON data format, toolkit.nu patterns, nu --ide-check debugging, the Nushell MCP server, and migration guide for updating scripts across Nushell versions (0.100–0.114: breaking changes, renamed commands, new idioms). Relevant when the user says "write nushell code," "review my .nu file," "nushell style," "nushell best practices," "format nushell," "nushell pipeline," "nutest," "NUON," "nu --ide-check," "nushell MCP," "update nushell script," "nushell breaking changes," or "nushell migration." |
Nushell Code Style Guide
Contents
| File | Topic |
|---|
| This file | Quick reference tables, do/don't checklists |
| patterns.md | Pipeline composition, command examples, code structure |
| formatting.md | Topiary conventions, spacing, declarations |
| debugging.md | --ide-check for agents, diagnostic parsing |
| nuon.md | NUON format, data serialization, config files |
| testing.md | nutest framework, snapshots, coverage |
| toolkit.md | toolkit.nu, repo utilities, commit conventions |
| mcp.md | Nushell as MCP server (nu --mcp), tools, persistent state |
| migration.md | Breaking changes, renamed commands, new idioms (0.100 → 0.114) |
| enhancements.md | New features to improve existing scripts (0.100 → 0.114) |
Agent Tip: Syntax Checking
When you finish writing or editing a .nu file, check it:
dotnu diagnose file.nu
It filters nu --ide-check output to real diagnostics and resolves byte spans to line numbers with source context — see debugging.md. Without dotnu, the raw fallback is:
nu --ide-check 10 file.nu | nu --stdin -c 'lines | each { from json } | where type == "diagnostic"'
Agent Tip: != and !~ in Bash
The Bash tool escapes ! → \!, breaking != and !~ in nu -c. Use a heredoc or temp file instead. See testing.md for workarounds.
Agent Tip: Literal ( in Interpolated Strings
\( is an escape only in double-quoted interpolation $"...". In $'...' the backslash is literal and ( still starts a subexpression — parens cannot be escaped there at all.
$"($header), changed \(trailing whitespace only\)" # ✓ → H, changed (trailing whitespace only)
$'($header), changed \(trailing whitespace only\)' # ✗ tries to run `trailing` as a command
If the string needs both interpolation and literal parens, use $"...".
Conciseness for Advanced Users
Write code that an experienced nushell user can quickly apprehend. Leverage implicit features:
| Verbose | Concise | Why |
|---|
update field {|row| $row.field | str uppercase} | update field { str uppercase } | Closure receives field value directly |
each {|x| $x | str trim} | each { str trim } | $in implicit, pipeline flows |
$list | each { str trim } | $list | str trim | Many commands accept list<string> directly (see below) |
where {|row| $row.status == "active"} | where status == "active" | where has field shorthand |
$data | each { $in | process } | $data | each { process } | $in passed automatically to first command |
Principle: If an advanced user knows how update, each, where work, they shouldn't need to parse redundant variable declarations.
Command Choices
| Task | Preferred | Avoid |
|---|
| Filtering | where | filter, each {if} | compact |
| List filtering | where $it =~ ... | where { $in =~ ... } |
| Parallel with order | par-each --keep-order | par-each (when order matters) |
| Pattern dispatch | match expression | Long if/else if chains |
| Record iteration | items {|k v| ...} | Manual key extraction |
| Table grouping | group-by ... --to-table | Manual grouping |
| Line joining | str join (char nl) | to text (context dependent) |
| Syntax check (human) | nu -c 'open file.nu | nu-check' | source file.nu |
| Syntax check (agent) | nu --ide-check 10 file.nu | nu-check (unstructured) |
| Membership | in operator | Multiple or conditions |
| Field extraction | get --optional | each {$in.field?} | compact |
| Negation | $x !~ ... | not ($x =~ ...) |
| List element ops | $list | str trim | $list | each { str trim } |
| Flags in code | save --force | save -f (short flags) |
Skip each When Commands Accept list<string>
Many commands accept both string and list<string> input — they operate on each element automatically. Wrapping them in each is redundant.
Heuristic: Check input_output types. If a command lists both string and list<string> as input, pipe the list directly.
# Check a command's accepted input types (help <cmd> returns rendered text, not data)
help commands | where name == 'str trim' | get input_output.0
# => [[input, output]; [string, string], [list<string>, list<string>], ...]
Common command families that accept list<string> directly: str (19 commands), path (9), split (4), into (4), ansi (3), url (2), fill.
# Preferred # Avoid
$list | str trim # $list | each { str trim }
$list | path expand # $list | each { path expand }
$list | ansi strip # $list | each { ansi strip }
$list | str replace 'a' 'b' # $list | each { str replace 'a' 'b' }
$list | url encode # $list | each { url encode }
each IS needed when the command does not accept list input, or when the closure does more than a single command call.
Shape caveat: split row on a list flattens all results into a single list (['a,b' 'c,d'] | split row ',' → [a b c d]), unlike each { split row ',' } which keeps one sublist per element. Other split commands (chars, words) keep sublists.
Pipeline Principles
Leading |
Place | at start of continuation lines, aligned with let.
Omit $in |
When body starts with pipeline command (each, where, select), input flows automatically.
Empty { } Pass-Through
Use empty { } for the branch that should pass through unchanged:
| if $cond { transform } else { } — transform when true, pass through when false
| if $cond { } else { transform } — pass through when true, transform when false
Stateful Transforms
Use scan for sequences with state: use std/iter scan
→ See patterns.md for detailed examples.
Script CLI Pattern
For toolkit-style scripts with subcommands (like nu toolkit.nu test):
# toolkit.nu
export def main [] { } # Entry point (required, even if empty)
export def 'main test' [--json] {
# nu toolkit.nu test
}
export def 'main build' [] {
# nu toolkit.nu build
}
Key points:
def main [] — entry point when running nu script.nu
def 'main subcommand' [] — defines nu script.nu subcommand
- Must define
main for subcommands to be accessible
- Use
export def if script is also used as a module
Script mode vs module mode
main is stripped in script mode but stays in module mode. export is irrelevant in script mode but required in module mode.
| How you run | Calls def "main test" | export needed? |
|---|
nu toolkit.nu test | ✓ main stripped | No |
use toolkit.nu; toolkit main test | ✓ main stays | Yes |
use toolkit.nu *; main test | ✓ bare names | Yes |
⚠ Common agent mistake — using use (module mode) but calling with script-mode syntax:
# WRONG: script-mode syntax after module-mode import
use toolkit.nu
toolkit test # Error: extra positional argument
# CORRECT: include `main` in the command path
use toolkit.nu
toolkit main test # ✓
# OR: just use script mode
# nu toolkit.nu test # ✓
When in doubt, prefer script mode (nu script.nu subcommand) — it's simpler and avoids the main path issue.
→ See Nushell Scripts docs
Module Naming Rule
When a file is named after the command (e.g., greet.nu), the command must be named main, not the file name:
# File: greet.nu
# WRONG — "Can't export ... named same as the module"
export def greet [name: string] { $"Hello ($name)" }
# CORRECT — `main` becomes the module's default command
export def main [name: string] { $"Hello ($name)" }
After use greet.nu, call it as greet "world" — main is replaced by the module name. This applies to def, extern, and const.
Quick Reference
Do
- Omit
$in | when command body starts with pipeline command
- Start continuation lines with
|
- Use empty
else { } for pass-through
- Use
match for type dispatch
- Use
in for membership testing
- Use
get --optional for field extraction
- Use
scan for stateful transforms
- Use
where for filtering
- Use
where $it =~ ... for list filtering
- Combine consecutive
each closures when operations can be piped
- Define data first, then filter
- Include type signatures:
]: input -> output {
- Use
@example attributes (nutest)
- Use
const for static data
- Keep custom commands focused
- Export ALL commands from implementation files (enables testing helpers)
- Control public API via
mod.nu re-exports (not by removing exports)
- Use
par-each --keep-order for parallel with deterministic output
Don't
- Start command bodies with
$in | when a pipeline command follows
- Use spread operator
... with conditionals (use data-first + where)
- Wrap external commands in unnecessary parentheses
- Over-extract helpers for one-time use
- Create wrapper commands that just call an existing command
- Use verbose names for local variables
- Break the pipeline flow unnecessarily
- Remove existing comments (preserve user's context)
- Remove
export from helpers to "make them private" (use mod.nu instead)
- Name a command the same as its file (use
main instead — see Module Naming Rule)
- Use short flags in code (
save -f, open -r) — write the long form (save --force, open --raw); short flags are for interactive typing
- Write
\( inside $'...' — single-quote interpolation has no escapes; literal parens need $"..." (see Agent Tip above)
- Declare short flag aliases (
--force (-f)) in command signatures unless the user explicitly asks for them
Formatting Summary
- Run
topiary format <file> when available — it is the canonical formatter
- Empty blocks:
{ } with space
- Closures:
{ expr } with spaces
- Flags:
--flag (-f) with space (declare a short alias only on explicit user request)
- Records: multi-line, no trailing comma
- Variables:
let x = (no $ on left)
→ See formatting.md for full conventions.