Skip to content

Nonanti/narwhal

narwhal

CI License: MIT OR Apache-2.0 Version Rust 1.75+

A TUI database client with a built-in MCP server. Five databases, vim editing, Lua plugins.

narwhal demo

Why narwhal

  • Built-in MCP server. Run narwhal mcp and any Model Context Protocol client (Claude Desktop, Cursor, your own agent) gets list_connections, describe_schema, describe_table, run_query, explain_query over stdio. Read-only by default, with a three-layer SQL guard and a workspace ACL (.narwhal/workspace.toml) so an agent can only see the connections you explicitly listed.
  • One TUI, five databases. Postgres, MySQL, SQLite, DuckDB, ClickHouse. No driver-juggling, no context-switching between psql, mysql, and DataGrip.
  • Vim editing + auto-pair + completion. Modal input (Normal, Insert, Visual), schema-aware tab-completion, alias-resolved column hints, a proper : command palette.
  • Lua plugin runtime. The bits that should be yours, stay yours. Write a .lua file, drop it in ~/.config/narwhal/plugins/, and it is live.
  • SSH tunnels, ~/.pgpass, OS keyring. The auth ergonomics you already configured for psql work here too. Set ssh_host=jump.example.com and the connect path forwards a loopback port for you.

Install

Cargo (any platform with Rust)

cargo install narwhaldb

The crates.io name is narwhaldb (the bare narwhal slot is held by an unrelated 2018 docker library); the installed binary is still just narwhal.

For users without a Rust toolchain, cargo-binstall fetches the prebuilt binary instead of compiling:

cargo binstall narwhaldb

Homebrew (macOS, Linuxbrew)

brew tap Nonanti/tap
brew install narwhal

Arch Linux (AUR)

yay -S narwhal      # or: paru -S narwhal

Nix

nix run github:Nonanti/narwhal

Or add the flake to your inputs and reference the default package.

Pre-built binaries

Download native tarballs (with SHA-256 checksums) from the latest GitHub Release:

  • x86_64-unknown-linux-gnu
  • aarch64-apple-darwin (Apple Silicon)

Intel Mac users: build from source with cargo install narwhaldb for now; a prebuilt x86_64-apple-darwin tarball is on the roadmap once the macos-13 runner backlog clears.

curl -LO https://github.com/Nonanti/narwhal/releases/latest/download/narwhal-1.0.0-x86_64-unknown-linux-gnu.tar.gz
tar -xzf narwhal-1.0.0-*.tar.gz
mv narwhal-1.0.0-*/narwhal ~/.local/bin/

Build from source

git clone https://github.com/Nonanti/narwhal.git
cd narwhal
cargo build --release
# binary at target/release/narwhal

Quick start

  1. Run narwhal. The TUI opens with an empty editor and a sidebar.
  2. Hit :add. The connection wizard appears. Pick a driver, fill in host + database (or use :url postgres://user:pass@host/db to skip the form).
  3. :open <name>. The saved entry connects; the sidebar fills with schemas and tables.
  4. F6 to run. The whole buffer executes; results appear in the lower pane. Press F1 any time for the full keymap reference.

connections.toml schema

Named connections live in ~/.config/narwhal/connections.toml. One [[connection]] block per database; [connection.params] carries the driver-specific options. The field names match the ConnectionParams struct — in particular username (not user) is the canonical name.

# Local SQLite — the file path is the only required param.
[[connection]]
id     = "00000000-0000-0000-0000-000000000001"
name   = "smoke"
driver = "sqlite"

[connection.params]
path = "/tmp/narwhal-smoke.db"

# Postgres on a non-default port, no TLS — typical local docker setup.
[[connection]]
id     = "00000000-0000-0000-0000-000000000002"
name   = "demo-pg"
driver = "postgres"

[connection.params]
host     = "127.0.0.1"
port     = 5433
username = "postgres"        # NOTE: `username`, not `user`
password = "narwhal"
database = "demo"
ssl_mode = "disable"         # disable | prefer (default) | require | verify-ca | verify-full

File-local drivers (sqlite, duckdb) tolerate the default prefer so pre-TLS configs still load; the wire layer ignores it. Network drivers (postgres, mysql, clickhouse) accept any of the five ssl_mode values plus optional ssl_root_cert, ssl_cert, ssl_key paths for mutual TLS.

config.toml (settings)

A ~/.config/narwhal/config.toml lets you override the renderer theme and a few editor toggles. Missing fields fall back to their defaults so a one-line file is enough.

theme = "dark"           # "dark" (default) | "light" | "high-contrast"

[editor]
tab_width    = 4         # reserved, v1.1 will honour this
use_spaces   = true      # reserved, v1.1 will honour this
line_numbers = true      # reserved, v1.1 will honour this

[keybindings]
vim_mode = true          # reserved, v1.1 will allow opt-out

v1.0 wires only the theme field; the rest are persisted and load-validated so the file stays stable across upgrades. The renderer warns at start-up if the file is malformed instead of silently falling back to defaults.

SSH tunnels

Any network connection can prepend an SSH local-port-forward by adding ssh fields to the params block. The forward is opened implicitly on :open and torn down when the session closes.

[connection.params]
host     = "db.internal"     # resolved on the bastion side
port     = 5432
username = "alice"
database = "prod"

[connection.params.ssh]
host      = "bastion.example.com"
user      = "alice"
port      = 22               # optional — defaults to 22
# key_path  = "~/.ssh/id_ed25519"  # optional — defaults to ssh-agent / ~/.ssh/config
# jump_host = "jump.example.com"   # optional — maps to `ssh -J`

The spawned ssh subprocess inherits ~/.ssh/config, the agent, Match blocks, IdentityAgent, and FIDO2 keys for free — narwhal deliberately shells out to OpenSSH rather than embedding its own client. URL form: ?ssh_host=bastion&ssh_user=alice on a :url postgres://... invocation.

TLS defaults changed (v0.2)

Breaking change: ssl_mode = prefer and ssl_mode = require now perform full CA chain verification instead of accepting any server certificate. Self-signed certificates will be rejected unless the CA is explicitly trusted via ssl_root_cert.

If you were relying on the previous insecure behaviour:

  • Self-signed servers: add ssl_root_cert = "/path/to/ca.pem" to the connection params, or set ssl_mode = "disable" if TLS is not needed.
  • Hostname mismatch: use ssl_mode = "require" or ssl_mode = "verify-ca" (chain verified, hostname skipped).
  • Full verification: ssl_mode = "verify-full" (unchanged).

Query-string TLS params (?sslmode=..., ?sslrootcert=..., etc.) are now parsed into dedicated struct fields instead of being left in the generic options map.

MCP server: talk to your databases through an AI agent

narwhal ships a built-in Model Context Protocol server so any MCP-capable AI assistant (Claude Desktop, Cursor, Continue, Aider, ...) can browse the connections you already configured and inspect their schema.

narwhal mcp   # runs the JSON-RPC stdio server

Wire it into Claude Desktop:

// ~/.config/Claude/claude_desktop_config.json
{
  "mcpServers": {
    "narwhal": {
      "command": "narwhal",
      "args": ["mcp"]
    }
  }
}

The v0 tool surface:

Tool What it does
list_connections List configured connections — driver, target, SSH flag. No IO, no credentials loaded. Honours the workspace ACL.
describe_schema Schema / table / view tree for one connection.
describe_table Full structure of one table — columns, indexes, foreign keys, unique constraints, engine-native DDL.
run_query Execute a single statement. Read-only by default — syntactic guard + BEGIN/ROLLBACK sandwich + row limit (default 1 000). read_only=false opts out, subject to the workspace ACL.
explain_query Driver-native EXPLAIN with the right dialect prefix. Optional analyze=true runs the statement for real cardinalities (PG / MySQL / DuckDB).

Every database-touching call is audit-logged to ~/.local/share/narwhal/history.jsonl with source: "mcp" so you can jq 'select(.source == "mcp")' to isolate agent traffic.

Workspace scoping: .narwhal/workspace.toml

A repo-local file (discovered by walking up from pwd, same idiom as .git) declares what the MCP server may expose when narwhal runs from inside that directory tree. Commit it next to your code so an agent launched against your project can only reach the databases you list.

# .narwhal/workspace.toml

# Connection names from connections.toml that the agent may target.
# Empty / omitted = all of them.
allowed_connections = ["staging", "test"]

# When false, run_query rejects read_only=false. Default true.
allow_writes = false

Disallowed connections appear to the agent exactly as a misspelled name would: the list_connections result hides them, describe_* / run_query calls answer with the same "unknown connection" tool-level error, and the agent retries against the visible set automatically.

Keymap

Global

Keys Action
F5 / Alt-Enter / Ctrl-; Run statement under cursor
F6 Run whole buffer
F7 Stream cursor statement
F4 / Ctrl-C Cancel running query
Ctrl-W Cycle pane focus
Ctrl-T New editor tab
Ctrl-Tab / Ctrl-Shift-Tab Cycle tabs
? / F1 Help
:q Quit
:refresh Re-fetch schema tree for active connection

Editor

Keys Action
i / a Enter insert mode
Esc Back to normal mode
Tab / Ctrl-Space Completion
↑ ↓ / Shift-Tab Cycle popup items
Enter / Tab (in popup) Accept completion
h j k l / arrows Move cursor
w / b Word forward / backward
0 / $ Line start / end
v / V Visual / visual-line mode

Sidebar

Keys Action
j / k / ↑ / ↓ Navigate
Enter Describe table
o Preview table data
d Inject DDL into editor

Results

Keys Action
h j k l / arrows Move selection
Enter Open cell popup
e Edit cell value
y / Y Yank cell / row to clipboard
/ Filter rows
n / N Next / prev search match
g / G Jump to first / last row
:next / :prev Page through results

Snippets

Keys Action
:save <name> Save editor buffer as a named snippet
:load <name> Load a snippet into a new tab
:rm-snippet <name> Delete a saved snippet
:snippets Browse saved snippets

Plugins

Plugins are Lua scripts that auto-load from ~/.config/narwhal/plugins/*.lua (or the platform equivalent under $XDG_CONFIG_HOME). They get a narwhal global with these entry points:

narwhal.register_command(name, description, handler)
    -- handler(arg : string)
    --   return "..."                 -> status bar message
    --   return { sql = "..." }       -> append to editor buffer
    --   return { sql = "...", append = false }
    --                                -> replace editor buffer
    --   return nil | false           -> silent

narwhal.register_transform(handler)
    -- handler(result : table)
    --   mutate in place; return value ignored

narwhal.sql_run(sql : string) -> result
    -- Run SQL on the active connection synchronously

narwhal.editor_text          : string (read-only)
    -- Current editor buffer content during command dispatch

Sample plugins

Six working samples live in examples/plugins/:

File What it does
uppercase.lua Result transform that uppercases every TEXT cell
format_json.lua Pretty-prints cells that parse as JSON
row_count.lua :rc <table> — count rows via narwhal.sql_run
query_snippet.lua :top <table> — inject SELECT * FROM … LIMIT 10
csv_export.lua :csv-export <table> <path> — dump to CSV
explain_cost.lua :explain-cost / :explain-sqlite — wrap buffer in EXPLAIN

Load on demand: :plug-load /path/to/file.lua. List everything: :plug-list.

For the full API reference, see narwhal-plugin-lua docs and the plugin examples README.

Security model

Plugins are trusted code that runs with your privileges. They can run arbitrary SQL, inject into the editor, and read every result row. Only install scripts from sources you'd trust as a shell script. There is no sandbox — by design, so auditing a plugin is just reading a short .lua file.

Built-in command names (run, open, begin, quit, …) are reserved; a plugin that tries to shadow one is rejected at load time. During a :begin transaction, narwhal.sql_run is refused entirely.

Headless exec mode: one-shot SQL from the shell

The same binary doubles as a psql -c / mysql -e muadili. Use it in CI smoke checks, cron jobs, or shell pipelines:

narwhal exec --conn prod 'SELECT count(*) FROM users'

# Choose a format for the consumer (default: table)
narwhal exec -c prod -f json 'SELECT id, email FROM users' | jq '.[].email'
narwhal exec -c prod -f csv  'SELECT * FROM orders' > orders.csv
narwhal exec -c prod -f tsv  'SELECT id, name FROM users' | column -t

# Limit rows to keep large queries snappy
narwhal exec -c prod -l 100 'SELECT * FROM events'

# Writes are sandboxed by default (BEGIN ... ROLLBACK). `--write` opts out:
narwhal exec -c prod --write "UPDATE users SET banned = true WHERE id = 42"

Formats: table (default, ASCII grid), csv (RFC 4180), json (array-of-objects), tsv (pipe-friendly, no quoting). Connection resolution is the same as the TUI — keyring first, ~/.pgpass/env fallback. Every call is audit-logged to history.jsonl with source: "exec" so it can be grepped alongside MCP traffic.

Transactions

Command Action
:begin [iso] Open a pinned connection; iso accepts ru/rc/rr/s short forms
:commit Commit and close the pinned connection
:rollback Rollback and close the pinned connection
:savepoint NAME Create a named savepoint (drivers that support them)
:release NAME Release a savepoint
:rollback-to NAME Rollback to a savepoint

A TX badge on the status bar reminds you that you're inside a transaction.

Architecture

              narwhal (bin)
                   │  entry point + CLI
                   ▼
             narwhal-app                       narwhal-mcp
              orchestrator                       MCP server
           │     │      │                          │
           ▼     ▼      ▼                          ▼
   narwhal-tui  narwhal-commands           narwhal-driver-registry
     render     completion / export /             (feature-gated
                wizard / dispatch /                 driver lookup)
                snippets / DDL / …                       │
           │         │                                    ▼
           └─────────┼────────────────┐         narwhal-driver-*
                    ▼                 │          postgres · sqlite
              narwhal-domain          │          mysql · duckdb
              pure model state        │          clickhouse
           (editor buffer, schema     │
            listings, no IO,          │
            no rendering)             │
                   │                  ▼
                   └────────►   narwhal-pool · narwhal-sql ·
                                narwhal-history · narwhal-config ·
                                narwhal-vim
                                       │
                                       ▼
                                  narwhal-core
                            driver trait, value model,
                            schema types, error type

Dependency direction: every arrow points downward. View (narwhal-tui) and commands (narwhal-commands) read domain state by reference; only narwhal-app is allowed to mutate it in response to user input. Concrete drivers are reached exclusively through narwhal-driver-registry, so cargo features are the only place where database engines come and go.

For the full layer map see docs/ARCHITECTURE.md; for the code-style contract see docs/STYLE.md.

Safety

  • Every destructive operation goes through the : command line, never a hotkey.
  • Statements are journaled to ~/.local/share/narwhal/history.jsonl before execution.
  • Passwords prefer the OS keyring; an in-memory fallback is used only when the keyring isn't available. :forget <name> wipes the cached entry.

Building

Nix

nix develop
cargo build --release

The dev shell pulls in cmake, clang, and libcxx for the bundled DuckDB C++ build, and pre-sets LIBCLANG_PATH for bindgen.

Other systems

Any toolchain at or above the version pinned in rust-toolchain.toml, plus the usual native build deps for DuckDB (cmake, a C++17 compiler).

cargo build --release

Benchmarks

Criterion harnesses live under crates/*/benches/. Run them all with cargo bench --workspace or one at a time:

cargo bench -p narwhal-sql --bench splitter
cargo bench -p narwhal-tui --bench sort
cargo bench -p narwhal-tui --bench editor_motion
cargo bench -p narwhal-history --bench append

The pre/post-optimisation numbers are recorded in docs/perf-after-phase-2.md; current headline numbers on a Linux box are ~900 MiB/s for the statement splitter, ~38 µs for a 5 000-line w motion, and ~1.15 ms to sort 2 000 JSON cells.

Contributing

A few ground rules so PRs land smoothly:

  • cargo fmt --all, cargo clippy --workspace --all-targets -- -D warnings, cargo test --workspace, and RUSTDOCFLAGS="-D warnings" cargo doc --workspace --no-deps all pass in CI; please run them locally too.
  • New behaviour ships with a regression test under the relevant crate's tests/ directory.
  • Commit messages follow Conventional Commits (feat:, fix:, refactor:, docs:, chore:).

Licence

Dual-licensed under the MIT and Apache 2.0 licences. Contributions are accepted under the same terms.


See docs/RELEASING.md for the release checklist and CHANGELOG.md for the version history.

About

TUI database client with a built-in MCP server. Five databases (postgres, mysql, sqlite, duckdb, clickhouse), vim editing, Lua plugins.

Topics

Resources

License

Unknown, MIT licenses found

Licenses found

Unknown
LICENSE-APACHE
MIT
LICENSE-MIT

Code of conduct

Contributing

Security policy

Stars

Watchers

Forks

Packages

 
 
 

Contributors

Languages