A fast shell script linter, formatter, and language server, written in Rust.
Shuck parses, analyzes, formats, and powers editor feedback for shell scripts. It catches common bugs, style issues, security hazards, performance traps, and portability problems; formats shell sources with configurable layout rules; and ships a first-party Language Server Protocol (LSP) server for editor diagnostics, fixes, navigation, symbols, hover, completion, and formatting. It also lints shell embedded in supported non-shell files such as GitHub Actions workflows. A caching layer keeps incremental runs fast.
- High performance — ~20-100x faster than ShellCheck
- Linting with rules across correctness, security, performance, portability, and style categories
- Safe and unsafe fix support for selected diagnostics
- Shell formatting with write, check, diff, stdin, and configuration-file modes
- Multi-dialect support: bash, sh/POSIX, mksh, zsh
- Automatic file discovery via extensions and shebang detection
- Embedded shell extraction for GitHub Actions workflows and composite actions
- First-party Language Server Protocol server for editor diagnostics, code actions, navigation, symbols, hover, completion, and formatting
- ShellCheck suppression compatibility (
# shellcheck disable=SC2086)
brew install ewhauser/tap/shuckpip install shuck-cliThe PyPI package is named shuck-cli, but it still installs the shuck
command.
cargo install shuck-cliPre-built binaries are available for macOS (aarch64) and Linux (x86_64) from the releases page.
Shuck's main workflows are split across subcommands:
| Workflow | Command |
|---|---|
| Lint files, directories, and supported embedded shell | shuck check |
| Format shell files | shuck format |
| Run the editor language server over stdio | shuck server |
| Remove project cache entries | shuck clean |
# Check files and directories
shuck check script.sh src/
# Check the current directory
shuck check .
# Check GitHub Actions workflow `run:` blocks
shuck check .github/workflows/ci.yml
# Check a composite action
shuck check action.yml
# Read from stdin
echo 'echo $foo' | shuck check -
# Apply safe fixes automatically
shuck check --fix .
# Apply opt-in unsafe fixes too
shuck check --unsafe-fixes .
# Skip the cache
shuck check --no-cache .
# Override the cache location
shuck --cache-dir .tmp/shuck-cache check .The formatter CLI is currently experimental, so set SHUCK_EXPERIMENTAL=1 when invoking shuck format:
# Format files in place
SHUCK_EXPERIMENTAL=1 shuck format .
# Check formatting without modifying files
SHUCK_EXPERIMENTAL=1 shuck format --check .
# Show unified diffs for files that would change
SHUCK_EXPERIMENTAL=1 shuck format --diff .
# Format stdin and infer the dialect from a filename
printf 'foo(){\necho hi\n}\n' | SHUCK_EXPERIMENTAL=1 shuck format --stdin-filename script.bash -
# Override formatting options for a single run
SHUCK_EXPERIMENTAL=1 shuck format --indent-style space --indent-width 2 --function-next-line .shuck format uses the same file discovery, exclusion, gitignore, project-root, and cache behavior as shuck check, but it only rewrites standalone shell files. Embedded GitHub Actions run: blocks are linted by shuck check; they are not rewritten by the formatter.
In --check and --diff modes, shuck format exits with status 1 when files would change and leaves the files untouched. Formatter parse errors exit with status 2.
Use the binary-backed hook when you want pre-commit to install shuck
without requiring a local Rust toolchain:
repos:
- repo: https://github.com/ewhauser/shuck
rev: v0.0.38
hooks:
- id: shuckReplace v0.0.38 with the release you want to pin.
If you would rather run from the checked-out Rust sources, or you are on a platform that does not have a published wheel yet, use the source hook instead:
repos:
- repo: https://github.com/ewhauser/shuck
rev: v0.0.38
hooks:
- id: shuck-srcThe shuck-src hook requires a working Rust toolchain because it runs
cargo run -p shuck-cli -- check ... from the cloned hook repository.
# Remove cache entries for the current project
shuck cleanShuck ships with a first-party Language Server Protocol (LSP) server in the main CLI. Editors and LSP clients should launch it over stdio:
shuck serverThe server analyzes the editor's in-memory buffer, publishes diagnostics as you edit, and reuses the same parser, lint rules, formatter settings, configuration, and fix machinery as the CLI. It currently supports incremental document sync, real-time diagnostics, quick fixes, source.fixAll.shuck, disable-this-line actions, whole-document and range formatting, hover help for rule codes in # shuck: and # shellcheck directives, completion, go-to-definition, references, document highlights, document symbols, and workspace symbols.
Any editor that can launch a stdio LSP server can use Shuck by pointing shell buffers at shuck server. See the editor integration guide for setup examples.
shuck check prints rich code-frame diagnostics by default:
warning[C001]: variable `tmp` is assigned but never used
--> deploy.sh:14:1
|
14 | tmp=$(mktemp)
| ^^^
|
Use --output-format concise to keep the legacy one-line format:
path:line:col: severity[CODE] message
deploy.sh:14:1: warning[C001] variable `tmp` is assigned but never used
deploy.sh:31:10: error[C006] undefined variable `DEPLY_ENV`
deploy.sh:45:3: warning[S005] legacy backtick command substitution
.github/workflows/ci.yml:12:11: warning[C001] jobs.test.steps[0].run: variable `summary` is assigned but never used
| Code | Meaning |
|---|---|
0 |
No issues found |
1 |
Lint violations or parse errors detected |
2 |
Runtime error (bad arguments, I/O failure) |
Shuck ships with rules organized into five categories:
| Category | Prefix | Description |
|---|---|---|
| Correctness | C | Bugs, errors, and likely mistakes. Enabled by default. |
| Style | S | Code quality and best-practice suggestions. |
| Performance | P | Inefficient patterns that have simpler or faster alternatives. |
| Portability | X | Bash-isms and shell-specific constructs that break under POSIX or other shells. |
| Security | K | Potentially dangerous shell patterns such as risky deletion, unsafe evaluation, or local expansion. |
Each rule has a short code (e.g., C006, S001) that appears in diagnostics and can be used in suppression directives. Diagnostics are classified as error, warning, or hint depending on severity.
Where possible, shuck rules align with ShellCheck rules. Shuck supports ShellCheck suppression syntax (# shellcheck disable=SC2086) and maps ShellCheck codes to their shuck equivalents, so existing suppression comments continue to work without changes. Both suppression syntaxes accept either code namespace, and native # shuck: disable=... follows ShellCheck's scope rules: before the first statement it is file-wide, otherwise it applies to the next command.
That said, shuck is not a port of ShellCheck. It is a clean-room reimplementation built on its own parser and analysis engine, so results will sometimes differ:
- Shuck's parser and analysis logic were written from scratch. Edge cases may be handled differently, and some diagnostics may fire in slightly different locations or contexts.
- In cases where ShellCheck's behavior appears incorrect or inconsistent with shell semantics, shuck intentionally chooses correctness over compatibility.
Compatibility is continuously validated against a large corpus of shell scripts from popular open-source projects including acme.sh, oh-my-zsh, nvm, pyenv, pi-hole, bats-core, powerlevel10k, dokku, gentoo, and many others. The latest conformance report is published at ewhauser.github.io/shuck/reports/corpus.
Suppress diagnostics with inline comments. Both native and ShellCheck-style directives are supported.
# Suppress a specific rule for the next command
# shuck:disable=C001
unused_var="ok"
# Suppress multiple rules
# shuck:disable=C001,S001
code_here
# Suppress for the entire file (place anywhere)
# shuck:disable-file=S001,S002
# ShellCheck-compatible syntax (also works)
# shellcheck disable=SC2034,SC2086
# Code aliases are interchangeable in either style
# shuck: disable=SC2086
# shellcheck disable=S001
# Before the first statement, disable becomes file-wide
# shuck: disable=S001For embedded GitHub Actions scripts, put suppression comments inside the run: block as shell comments:
- run: |
# shellcheck disable=SC2086
echo $FOOYAML comments outside the run: scalar are not visible to the shell parser and do not suppress shell diagnostics.
Project settings live in .shuck.toml or shuck.toml.
Use the [check] section to control embedded-script extraction:
[check]
# Lint supported embedded shell scripts in non-shell files such as
# GitHub Actions workflows and composite actions.
# Default: true
embedded = true
[lint]
# Override shell dialect inference for matching files.
per-file-shell = { "scripts/bash/**" = "bash", "vendor/**/*.sh" = "sh" }
# Add shell overrides on top of earlier config or CLI layers.
extend-per-file-shell = { "tools/**/*.zsh" = "zsh" }
[format]
# Configure `shuck format` and editor formatting.
indent-style = "tab" # tab | space
indent-width = 4 # used when indent-style = "space"
binary-next-line = false # put binary operators on continuation lines
switch-case-indent = false # indent case branch bodies
space-redirects = false # add spaces around redirection operators
keep-padding = false # preserve safe horizontal padding
function-next-line = false # put function opening braces on their own line
never-split = false # prefer compact layoutsFormatter dialect is inferred from the file name or shebang. For stdin or one-off formatting runs, use shuck format --dialect bash|posix|mksh|zsh.
When given a directory, shuck recursively discovers standalone shell scripts by:
- Extension:
.sh,.bash,.zsh,.ksh,.dash,.mksh,.bats - Shebang: files starting with
#!/bin/bash,#!/usr/bin/env sh, etc.
Shuck also discovers embedded shell in supported non-shell files:
- GitHub Actions workflows:
.github/workflows/*.ymland.github/workflows/*.yaml - Composite actions:
action.ymlandaction.yaml
For GitHub Actions files, shuck lints run: blocks independently, remaps diagnostics back to the host YAML file, and includes the step path (for example jobs.test.steps[0].run) in the message. Steps that target unsupported shells such as PowerShell or cmd are skipped.
The following directories are skipped by default: .git, .hg, .svn, .jj, .bzr, .cache, node_modules, vendor, .shuck_cache.
Gitignore and .ignore files are respected by default. Use --no-respect-gitignore to disable.
Shuck caches lint and format results per file in a shared cache root outside the project tree by default. The default location follows the OS cache directory convention, which is typically ~/Library/Caches/shuck on macOS and $XDG_CACHE_HOME/shuck or ~/.cache/shuck on Linux.
Override the cache root with --cache-dir or SHUCK_CACHE_DIR.
Disable caching with --no-cache or remove a project's cache entries with shuck clean [PATH].
Shuck builds on ideas and inspiration from several excellent open-source projects. This section is a thank-you to those communities — it does not imply endorsement, affiliation, or any formal relationship between shuck and these projects.
- bashkit — shuck-parser was originally forked from bashkit's bash lexer and parser; it has since evolved substantially to meet the needs of a linter (comment and trivia preservation, error recovery, multi-dialect parse views, extended AST coverage).
- Ruff — Linter architecture inspiration, particularly around caching, rule organization, and diagnostic output.
- ShellCheck — An amazing project and the original source of inspiration for shuck. ShellCheck set the standard for shell script analysis.
- gbash — A lot of lessons learned from this earlier project carried forward into shuck.
MIT