A TUI database client with a built-in MCP server. Five databases, vim editing, Lua plugins.
- Built-in MCP server. Run
narwhal mcpand any Model Context Protocol client (Claude Desktop, Cursor, your own agent) getslist_connections,describe_schema,describe_table,run_query,explain_queryover 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
.luafile, drop it in~/.config/narwhal/plugins/, and it is live. - SSH tunnels,
~/.pgpass, OS keyring. The auth ergonomics you already configured forpsqlwork here too. Setssh_host=jump.example.comand the connect path forwards a loopback port for you.
cargo install narwhaldbThe crates.io name is
narwhaldb(the barenarwhalslot is held by an unrelated 2018 docker library); the installed binary is still justnarwhal.
For users without a Rust toolchain, cargo-binstall fetches the
prebuilt binary instead of compiling:
cargo binstall narwhaldbbrew tap Nonanti/tap
brew install narwhalyay -S narwhal # or: paru -S narwhalnix run github:Nonanti/narwhalOr add the flake to your inputs and reference the default package.
Download native tarballs (with SHA-256 checksums) from the latest GitHub Release:
x86_64-unknown-linux-gnuaarch64-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/git clone https://github.com/Nonanti/narwhal.git
cd narwhal
cargo build --release
# binary at target/release/narwhal- Run
narwhal. The TUI opens with an empty editor and a sidebar. - Hit
:add. The connection wizard appears. Pick a driver, fill in host + database (or use:url postgres://user:pass@host/dbto skip the form). :open <name>. The saved entry connects; the sidebar fills with schemas and tables.- F6 to run. The whole buffer executes; results appear in the lower pane. Press F1 any time for the full keymap reference.
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-fullFile-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.
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-outv1.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.
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.
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 setssl_mode = "disable"if TLS is not needed. - Hostname mismatch: use
ssl_mode = "require"orssl_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.
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 serverWire it into Claude Desktop:
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.
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 = falseDisallowed 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.
| 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 |
| 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 |
| Keys | Action |
|---|---|
| j / k / ↑ / ↓ | Navigate |
| Enter | Describe table |
| o | Preview table data |
| d | Inject DDL into editor |
| 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 |
| 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 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 dispatchSix 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.
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.
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.
| 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.
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.
- Every destructive operation goes through the
:command line, never a hotkey. - Statements are journaled to
~/.local/share/narwhal/history.jsonlbefore 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.
nix develop
cargo build --releaseThe dev shell pulls in cmake, clang, and libcxx for the bundled
DuckDB C++ build, and pre-sets LIBCLANG_PATH for bindgen.
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 --releaseCriterion 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 appendThe 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.
A few ground rules so PRs land smoothly:
cargo fmt --all,cargo clippy --workspace --all-targets -- -D warnings,cargo test --workspace, andRUSTDOCFLAGS="-D warnings" cargo doc --workspace --no-depsall 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:).
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.
