@vascent/lazysql
v0.1.37
Published
A lazygit-style TUI database manager.
Readme
lazysql
English · 简体中文
A lazygit-style terminal database client (TUI). Keyboard-driven, panel-based, cross-database, and generates SQL from natural language — all without leaving the keyboard.
Work with databases in your terminal the way lazygit works with git: connect, browse, edit, query, manage schema.

✨ Features
- 🔌 Multiple data sources: PostgreSQL · MySQL/MariaDB · SQLite · MongoDB · Redis
- 📊 Browse / edit: pagination · column sort · column filter · schema-aware cell editors (text/JSON, enum/boolean choices, parent-row picker, explicit NULL) · offline Quick Add with database/program/reference/user-owned fields · row-level edit and delete (parameterized, run inside a real transaction, auto-rollback when
affected≠1) - ⌨️ SQL editor: multi-line editing · cancellable execution with live elapsed time · per-connection persistent history (
^P/^N) · schema + dialect-aware completion with typed keyword/table/column/schema candidates (Tab/Shift+Tab to select, Enter or click to accept) · collapses to a one-line echo bar (^O) so the grid keeps the screen - 📤 Data export: to CSV · JSON · SQL — the current grid view, a whole table, an entire schema/category, or a multi-select of tables (
vto mark); streamed to disk at constant memory, cancellable (esc), with a live row count. CSV writes one file per table; JSON and SQL each combine into a single file. JSON-typed columns (jsonb,json, …) are embedded as native JSON, not escaped strings - 🧬 Schema introspection: tables / views / indexes / sequences / triggers / stored procedures / Postgres enums; inspect an object's columns (enum columns show their allowed values, foreign-key columns show what they point at) and DDL definition
- 🛡️ Destructive-operation guard: a
WHERE-lessUPDATE/DELETE,DROP, orTRUNCATEalways pops a centered confirmation dialog echoing the full SQL to be run; when a PostgresDROPfails due to dependents, it offers aCASCADEretry and names the objects that would be dropped along with it - 🌳 Object tree: filter objects by name with
/orf(live-narrows as you type) · auto-refreshes after a successful DDL (CREATE/DROP/ALTER/…) - 🤖 NL→SQL: press
^G, describe a query or INSERT, and the LLM generates SQL placed into the editor for review (never auto-executed); if a declared foreign key needs a specific parent, choose it in the searchable picker instead of accepting a placeholder;↑/↓recalls submitted prompts,esccancels, and^Frepairs a failed statement for review - 🗂️ Connection management: multi-connection config · create / edit / test connections in-TUI · passwords stored separately from config (optional OS Keychain) · SSH tunnel to databases behind a bastion (key/agent auth,
~/.ssh/configaliases work) - 🔎 Action search:
F1searches and runs the actions available in the current context; the palette, footer, help and shortcuts all derive from the same capability-gated keymap - 🖱️ Modern terminal UX: mouse / scroll wheel · clickable Data/DDL tabs, sortable headers and completion candidates · system-clipboard copy · scrollable long dialogs · narrow-terminal-safe overlays · full-cell and whole-record inspectors (long text wraps by display width, no CJK truncation; timezone-aware instants retain the stored value and add the workstation-local time)
📦 Installation
Ready to run, no Bun required — prebuilt bun --compile native binaries are distributed via npm, one per platform; only the one matching your system gets installed. Supports macOS (Apple Silicon) · Linux (x64 / arm64) · Windows (x64).
Package managers (recommended)
| Method | Command |
|------|------|
| npm | npm i -g @vascent/lazysql |
| bun | bun add -g @vascent/lazysql |
| Try without installing | npx @vascent/lazysql --list |
Then just type lazysql.
When installing with
bun add -g, iflazysqlreports command not found, Bun's global directory~/.bun/binisn't on yourPATH—bun add -gusually prints a hint to add it (npm i -ggenerally doesn't have this issue).
From source (to try unreleased changes)
To run the latest unreleased code, or use your local edits right away:
git clone https://github.com/Yangeyu/lazysql && cd lazysql
bun install
bun link # register a global lazysql → ~/.bun/bin/lazysql (symlinked to this repo)bun link puts the command in Bun's global directory ~/.bun/bin; make sure it's on your PATH (command -v lazysql returns a path). If not, add one line — exactly the same config bun add -g needs:
echo 'export PATH="$HOME/.bun/bin:$PATH"' >> ~/.zshrc && source ~/.zshrcThe symlink points at the repo, so git pull or code edits take effect immediately — no reinstall; undo with bun unlink inside the repo.
Upgrade
npm i -g @vascent/lazysql@latest # bun: bun add -g @vascent/lazysql@latestA from-source install upgrades via git pull — the symlink picks it up immediately.
Uninstall
npm un -g @vascent/lazysql # bun: bun remove -g @vascent/lazysql; from source: bun unlinkUninstalling only removes the program itself. Config and history (connections, passwords, SQL history) stay in ~/.config/lazysql/; to wipe them too:
rm -rf ~/.config/lazysqlOn macOS, if you ever set
LAZYSQL_SECRETS=keychain, passwords live in the system keychain and aren't removed by deleting~/.config/lazysql— search forlazysqlin Keychain Access and delete the matching entries manually.
🚀 Usage
lazysql # open the default (first saved) connection
lazysql <name> # open a saved connection by id / name
lazysql <file.db> # open an ad-hoc SQLite file
lazysql --list # list saved connections and exitThe first run auto-creates ~/.config/lazysql/connections.yml. Inside the TUI: press ? for the full keymap, ` to switch connections, and n to create one — see the Keybindings section below for the rest.
⌨️ Keybindings
vim-style, panel-based. Grouped by context below; the full list is in-app via ? (the footer and help both render from the same keymap table, so they never drift).
Global
| Key | Action |
|----|------|
| F1 | Search and run actions available in the current context |
| ` | Switch connection (back to the picker) |
| : | Enter and focus the SQL editor (expands it if collapsed) |
| ^O | Expand / collapse the SQL editor — collapsed it is a one-line echo of the SQL behind the grid |
| ^G | Ask in natural language (opens the SQL editor) |
| ^F | Ask AI to fix the failed SQL — the statement and the database error are sent to the provider, the rewrite lands in the editor for review (only offered after a run failed) |
| tab | Toggle focus between tree ↔ results |
| ^H / ^L | Focus the tree / the results grid directly |
| _ / + | Shrink / widen the connections sidebar |
| ! | Reopen the current error details |
| F12 | Toggle the runtime debug console (logs and unhandled errors) |
| ? | Toggle help |
| q | Quit |
Failures (a refused write, a lost connection, a failed export…) pop an error dialog with the full message, the driver's error code and its detail. Scroll long diagnostics with j/k, arrows or the wheel; esc / ⏎ dismisses it and ! reopens the retained details.
Sidebar (tree)
| Key | Action |
|----|------|
| k / j · ↑ / ↓ | Move selection |
| ⏎ / space | Expand / collapse / open object |
| → / l | Expand |
| ← / h | Collapse / jump to parent |
| a | Clean SELECT * browse of the selected table |
| / or f | Filter objects by name — live-narrows the tree (⏎ keep · esc clear) |
| v | Mark / unmark a table for a batch export (multi-select) |
| X | Export — marked tables, else all tables under the node (schema / category), else this one |
| esc | Clear the tree filter, else all export marks |
| g / G | Jump to first / last |
| D | View the object's DDL / structure |
| d | Draft a DROP into the editor |
| r | Refresh connection and object tree |
| n / e | New / edit connection |
| x | Remove the selected connection |
Results grid
| Key | Action |
|----|------|
| k / j | Move row cursor |
| h / l · ← / → | Move column cursor · horizontal scroll for wide tables |
| g / G | Jump to first / last row |
| 0 / $ | Jump to first / last column |
| ^U / ^D | Half page up / down |
| ⏎ | Inspect the full cell value |
| v | Inspect every value in the current row |
| y | Copy the focused cell's full value to the clipboard |
| a | Browse the selected table (SELECT *) |
| s | Cycle sort (asc → desc → none) |
| / | Filter by column substring |
| esc | Undo the latest applied filter — restore its previous page, sort and focused cell |
| i | Quick-add one row without AI (available on relational tables with safe preview/write capabilities) |
| d | Delete row (⏎ inspect, then e edits the cell) |
| X | Export the view — a browsed table to CSV / JSON / SQL (filtered & sorted), a query result to CSV / JSON |
| r | Refresh the view — re-fetch the browsed window (same page / sort / filter), or re-run the query (reads only) |
| n / p | Next / previous page |
| D | Toggle Data / DDL tab |
The Data/DDL tabs are clickable. Clicking a column header selects it and cycles the active browse sort through ascending, descending and none; dragging cell text still uses native terminal selection.
Quick Add (manual fallback)
For ordinary natural-language inserts, press ^G and describe the change; the generated SQL is placed in the editor and never auto-executed. Quick Add is the deterministic fallback for no-Key/offline use and small, exact single-row changes.
| Key | Action |
|----|------|
| tab / ⇧tab · ↓ / ↑ | Move to the next / previous field |
| ^D | Omit the field from the INSERT |
| ^N | Insert an explicit NULL |
| ^E | Insert an explicit empty string |
| ^Space | Cycle a declared boolean or enum choice |
| ⏎ | Choose a parent row for a reference field, or a declared enum value; otherwise validate and review the exact INSERT |
| esc | Discard the insert draft |
Database default/generated fields are omitted and skipped. A required single-column primary ID is generated once by the program from the adapter-declared field constraint (UUID or bounded opaque text today); the text constraint carries both its length and allowed alphabet, rather than hiding one hard-coded ID format inside the generator. The ID is never shown as a user input and stays stable through review cancellation and database failures. If no safe format can be declared, Quick Add stops instead of falling back to a raw ID field. Declared foreign keys open the parent-row picker and are never generated. Declared enum columns (Postgres enum types, MySQL ENUM(...)) never take free text either — ⏎ picks from their declared values, so an invalid value has no path to the database (MySQL's lenient mode would otherwise silently store ''). Validation jumps to the exact field. It is currently enabled for SQL tables; MongoDB/Redis remain gated until their typed-value input contract can preserve BSON/Redis value kinds safely.
Parent-row picker (Quick Add, cell edit, or Ask AI clarification)
Rows lead with a safe human-readable label when one is declared (for example name or title), followed by the exact referenced key when space allows. Choosing still binds only the declared key columns; composite keys are applied as one patch.
| Key | Action |
|----|------|
| ↑ / ↓ · k / j | Move between parent rows |
| / | Search by the displayed human-readable field |
| n / p | Next / previous page |
| ⏎ | Bind the selected row's declared key |
| ^N | Clear every child column to NULL (nullable cell relation only) |
| esc | Return without choosing (or cancel search first) |
Enum-value picker (Quick Add or cell edit)
| Key | Action |
|----|------|
| (typing) | Filter the declared values live (case-insensitive substring — every letter goes to the filter) |
| ↑ / ↓ | Move between matching values |
| ⏎ | Use the highlighted value and continue to review |
| esc | Keep the previous value and return |
DDL / structure tab
| Key | Action |
|----|------|
| ↑/↓ k/j | Scroll the structure / DDL |
| g / G | Jump to the first / last line |
| D | Toggle Data / DDL tab |
SQL editor
The collapsed SQL echo bar keeps mouse gestures selection-only and never focuses or expands. Once expanded, clicking the pane focuses the editor; dragging still selects and copies text without changing the gear.
| Key | Action |
|----|------|
| ⏎ | Accept the selected completion when candidates are visible; otherwise run the query |
| ⇧⏎ | Insert a newline — compose multi-line SQL |
| tab / ⇧tab | Select the next / previous completion candidate; without candidates, move pane |
| ^P / ^N | Previous / next history entry |
| ^T | Toggle schema-aware completion |
| ^G | Generate SQL from natural language (esc or clicking a pane/SQL editing area exits the prompt; esc cancels generation) |
| ^F | Fix the failed SQL with AI — statement + database error sent to the provider, rewrite lands in the editor for review (offered after a run fails) |
| ↑ / ↓ (Ask AI) | Recall the previous / next submitted prompt (memory-only; cleared on connection switch) |
| ^C | Clear the draft |
| ^O | Collapse the editor to its one-line echo bar (the draft is kept, flagged (draft)) |
| esc | Dismiss visible completions first; press again to return to the results grid. While a query is running, cancel it and keep the previous grid |
Confirmation dialog
| Key | Action |
|----|------|
| y | Apply the pending write / run the export |
| n | Cancel |
| f | Cycle export format (CSV / JSON / SQL) — export confirm only |
| j / k · ↑ / ↓ | Scroll a long statement or detail section |
Cell inspector
| Key | Action |
|----|------|
| j / k · ↑ / ↓ | Scroll the value |
| g / G | Jump to the first / last line |
| e | Edit using the schema-selected editor |
| y | Copy the full value to the clipboard |
| q / esc / ⏎ | Close |
Text and JSON fields use the multi-line editor: Enter inserts a newline, ^S reviews the exact UPDATE, ^N selects database NULL when allowed, and ^E selects an empty string. NULL and '' are distinct states. Declared enum and boolean fields use a searchable closed-value picker. A declared foreign key opens the parent-row picker and updates every column of a composite key together. Database-generated columns remain read-only; when the source cannot declare richer field facts, editing conservatively falls back to text. Every change still passes through the existing y/n statement confirmation.
For an adapter-declared instant (for example Postgres timestamptz, MySQL TIMESTAMP, or MongoDB Date), the view keeps the stored value first and appends the workstation-local projection in parentheses. Offset-free timestamp / DATETIME, DATE, and TIME values are never assigned a timezone. Editing and clipboard copy continue to use the stored value only.
Referenced values and Redis collections
⏎ views one value and v views the whole record through the same content-reading path. Redis previews are resolved before either inspector opens: String stays text, Hash becomes a JSON object, List/Set a JSON array, and ZSet an array of { member, score }. Stored strings and score text are preserved, without guessing nested JSON or numeric types. There is no separate Redis navigation mode; scrolling, copying and closing use the ordinary inspector keys.
Referenced content is read-only. y on a grid preview opens the value first; inside a complete value or record it copies resolved content. Redis inspections show at most 100 collection members and 64 KiB of text. Any unfinished scan or clipped content is visibly marked TRUNCATED and cannot be copied as a complete value/record. Key-list exports still export list previews, not a full Redis dump.
Record inspector
| Key | Action |
|----|------|
| j / k · ↑ / ↓ | Scroll the vertically expanded record |
| g / G | Jump to the top / bottom of the record |
| y | Copy the frozen row as lossless JSON |
| q / esc / ⏎ | Close |
v captures the selected record, resolves any referenced previews, and shows every column in result order. Duplicate names from joins remain separate by ordinal; NULL, empty strings, JSON, binary previews, and local-time annotations stay visibly distinct. Pressing lowercase y copies pretty JSON shaped as { "fields": [{ "name": "id", "value": 1 }] }, so column order and duplicate names are preserved. Declared JSON stays nested, bigint and binary values use the same safe string representation as JSON export, and timestamps use stored values rather than local-time annotations. Uppercase Y remains unbound. The view is read-only and works for both table browsing and arbitrary query results. Already-loaded values require no additional database reads.
New / edit connection form
| Key | Action |
|----|------|
| ↑ / ↓ | Move between the driver, fields and buttons |
| ← / → | Cycle the driver / focused button (on that row) |
| ^R | Show / hide password |
| ^T | Test the connection (without saving) — reports the visible object count |
| ⏎ | Save — or press the focused button |
| esc | Cancel |
Port/DB fields accept digits only; required fields are validated on save (the error names the field). Everything is also clickable. Terminal paste works in every field (including Cmd+V on macOS); the Password value stays masked until ^R reveals it. Editing a connection loads its saved password; if none is stored, the field stays blank.
Paste a connection URL (postgres://, mysql://, mongodb://, redis://) into the URL row and press ⏎ to fill the whole form — driver, host, port, user, password and database are split out automatically; a name you typed is kept. mongodb+srv and rediss URLs are not supported by the form (use the url option in connections.yml).
Redis connections use RESP2, including password-only authentication on older servers without HELLO. The DB index is zero-based; its upper limit comes from the server, not a fixed 0–15 range.
For a database behind a bastion, fill the SSH row with user@host[:port] (or a ~/.ssh/config alias) and optionally an SSH key path — see SSH tunnel below.
⚙️ Configuration
All under ~/.config/lazysql/:
| File | Contents |
|------|------|
| connections.yml | Connection config, no passwords, editable by hand |
| secrets.json | Passwords (chmod 600) |
| config.yml | App settings (incl. the NL→SQL llm: block) |
| history.json | Per-connection SQL history (capped at 100 entries each) |
Passwords default to secrets.json; on macOS, set LAZYSQL_SECRETS=keychain to use the system keychain instead (zero native dependencies).
SSH tunnel
Reach a database behind a bastion by adding an ssh: block to a connection (or filling the SSH rows in the in-TUI form). lazysql runs the system ssh for a local port forward, so your ~/.ssh/config, keys and agent all apply — host can simply be a config alias:
connections:
- id: prod
name: prod
driver: postgres
options:
host: db.internal # as seen FROM the bastion
port: 5432
user: app
database: app
ssh:
host: bastion.example.com # or a ~/.ssh/config Host alias
user: ubuntu # optional
port: 22 # optional
keyFile: ~/.ssh/id_ed25519 # optional (-i); agent/config keys work without itApplies to PostgreSQL / MySQL / MongoDB / Redis, with discrete host/port options only — a url/connectionString option can't be tunneled (its embedded host can't be rewritten). Auth is key/agent only: the TUI owns the terminal, so an interactive SSH password prompt can't be answered (BatchMode is forced — a connection that would prompt fails fast with ssh's error instead of hanging).
A tunneled MongoDB connection is pinned with directConnection=true — replica-set member addresses only resolve from the far side of the tunnel. If the SSH link dies, keepalives make ssh give up within ~90s rather than hold a dead forward open; press r to reconnect.
NL→SQL (LLM)
The provider is isolated behind the SqlGenerator port; without an API key, ^G is silently disabled. While generation is running, esc aborts the provider request, keeps the current SQL draft untouched, and ignores any late response. Pin the provider in config.yml, or override temporarily with environment variables (env wins). The API key is read only from the environment, never written to config.yml.
What the model is told. Each table is sent as name(column type PK/NOT NULL ['enum','values'], …) — declared types, key roles, nullability and enum values all come from the same introspection that fills the DDL tab, so the model doesn't have to guess that status accepts 'in_progress' rather than 'active'.
Which tables are sent. Any table you name in the question is always sent — matched on whole identifier tokens, so orders finds orders without order dragging in order_items. Beyond that, with a table open lazysql starts from it and follows declared foreign keys two hops out, then adds the rest of that table's own schema; everything else is reported by name and count instead ("151 further table(s) exist … say so instead of guessing"). On a real database sharing a server with a framework's own schema, that is 17 tables instead of 168 for better answers. Sources that can't report foreign keys (or databases that declare none) are unaffected: nothing is dropped, and a column budget trims only if the whole schema won't fit. With no table open, everything within that budget is sent.
When an INSERT depends on another row. lazysql never asks the model to invent a foreign-key placeholder or pick an arbitrary LIMIT 1 row. The generator can pause with a structured input request; you choose the parent by a readable label, lazysql returns the exact declared key to the same generator, and only the resulting SQL is placed in the editor. No SQL is executed by this clarification flow.
When the guess is wrong. Tables that weren't sent are still listed by name, and the model can ask for any of them instead of answering from a table that merely looks similar — lazysql fills in that table's columns and asks once more. Exactly once, and only when something was held back, so an ordinary question is still a single round trip.
When the SQL is wrong. If a generated or hand-written statement fails to run, ^F sends that exact statement and the database's error verbatim to the model—never an unrelated prompt from Ask AI history. The rewrite lands in the editor for review and is never auto-executed. Each key press starts one bounded repair request; there is no automatic retry loop. If you run the rewrite and the database rejects it again, the new failure enables ^F again. The diagnostic may contain data values, so it leaves the machine only after this explicit key press.
config.yml:
llm:
provider: alibaba # anthropic | alibaba | openai | deepseek | moonshot
model: qwen3.7-plus # optional, overrides the default model
# baseUrl: https://... # optional, overrides the default base URL (e.g. an overseas endpoint)| Provider | id | API key env var | Default model |
|----------|----|------------------|----------|
| Alibaba Cloud (Qwen) | alibaba | DASHSCOPE_API_KEY | qwen3.7-plus |
| OpenAI | openai | OPENAI_API_KEY | gpt-4o |
| DeepSeek | deepseek | DEEPSEEK_API_KEY | deepseek-chat |
| Moonshot AI (Kimi) | moonshot | MOONSHOT_API_KEY | kimi-k2.6 |
| Anthropic (Claude) | anthropic | ANTHROPIC_API_KEY | claude-opus-4-8 |
Moonshot defaults to the mainland-China host (api.moonshot.cn); from outside China set baseUrl/LAZYSQL_LLM_BASE_URL to https://api.moonshot.ai/v1.
When no provider is set explicitly, it's auto-detected by "which API key exists" (Qwen first, Anthropic last). Each run can also override with LAZYSQL_LLM_PROVIDER / LAZYSQL_LLM_MODEL / LAZYSQL_LLM_BASE_URL.
export DASHSCOPE_API_KEY=sk-xxx && lazysql # Qwen by default
ANTHROPIC_API_KEY=sk-ant-xxx LAZYSQL_LLM_PROVIDER=anthropic lazysql # temporarily switch to Claude🏗️ Architecture
Tech stack: TypeScript (strict) · Bun · OpenTUI (@opentui/react + React 19) · Zustand · yaml. LLM via @anthropic-ai/sdk + any OpenAI-compatible backend (Qwen / OpenAI / DeepSeek / Kimi).
Clean / Hexagonal layering, built around a capability-segmented DataSource port — the UI asks a data source "which capabilities do you support" (Queryable / Browsable / RowEditable / Transactional / …) rather than "what type are you", so adding a database = adding one adapter, with zero changes to the core. Every layer boundary is crossed via a Result<T,E> + port handshake; inner layers never depend on outer ones.
src/
domain/ pure business rules / entities / value objects (no IO)
application/ ports (outbound interfaces) + usecases (use-case orchestration)
adapters/ datasource · llm · persistence · clipboard (real IO)
presentation/ app · components · keymap · tree (TUI, unidirectional data flow + Zustand)
shared/ cross-layer pure utilities (e.g. Result)The source of truth for the design is docs/ARCHITECTURE.md; key decisions are recorded in docs/adr/ (capability model, TUI framework, NL→SQL provider strategy, key dispatch, etc.).
🛠️ Development
Requires Bun. SQLite uses Bun's built-in
bun:sqlite— no native dependency.
bun install # install dependencies
bun run seed # generate the sample DB data/sample.db
bun start # run from source (the launch-arg forms from Usage work here too, e.g. bun start <name>)Tests
bun run typecheck # strict type check
bun test # unit + reachable adapter contracts + persistence + headless TUI integration
bun run test:contracts:live --down # own all four server DBs; fail on any skipped contract
bun run eval # SQLite/PostgreSQL/MySQL NL→SQL matrix; unavailable targets may skip
bun run eval:full # explicit local eval: require your provider key + all three targets
bun run eval --update-baseline # replace all selected dialect baselines after a fully passing runbun run eval is the judge for prompt, schema-context, repair and dialect changes. It drives the real store → catalog → provider path against SQLite, PostgreSQL and MySQL; repair cases first obtain a real database failure and invoke the same action as ^F. Reads compare execution results. Writes run inside the target's real transaction, must satisfy versioned affected-row and postcondition checks, then roll back—SQL substring matching is not a verdict. Cases live in scripts/eval/cases.yaml; only genuine syntax differences use per-dialect gold SQL. Each dialect has its own baseline whose identity includes provider/model, dialect, and a SHA-256 of the ordered schema plus complete seeded rows. A comparable baseline rejects any previously-passing regression or score drop. Missing/mismatched baselines do not waive failures: the current target must pass every case; malformed baselines fail immediately, and --update-baseline writes only a fully passing run. Live-provider evaluation stays outside bun test; eval:full rejects a missing provider or target and is run explicitly by a developer who owns the required API key. It is never a CI or release prerequisite.
The local judge of "done" is bun run typecheck && bun test all green. Every data-source adapter passes a shared contract (the executable acceptance of LSP); normal tests skip automatically when a server is unreachable, so a machine without Docker remains usable. CI and release require stronger deterministic evidence: test:contracts:live starts PostgreSQL, MySQL, MongoDB and Redis from docker-compose.test.yml, waits for health, runs only the live contracts and fails if JUnit reports even one skip. A user's paid LLM credential is never required to build or publish lazysql.
bun run test:contracts:live --downContributors: please read CLAUDE.md (working rules: layering, naming, commit conventions) and docs/ARCHITECTURE.md first. Commits follow Conventional Commits (enforced by .githooks + commitlint); on bun install, the prepare script points core.hooksPath at .githooks.
📄 License
MIT © yangwb
