Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
89 changes: 87 additions & 2 deletions docs/reference/cli.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,9 +9,9 @@ The `dirsql` binary has these modes:
| `dirsql query "<sql>"` | Explicit synonym for the default one-shot query. |
| `dirsql server` | Start a long-lived HTTP server exposing a SQL view of a directory. See [HTTP API](./http-api.md). |
| `dirsql init` | Generate a `.dirsql.toml`. |
| `dirsql` (bare) | Open a [REPL](#the-repl) over the current directory, reading statements until EOF. |

Bare `dirsql` with no SQL is a usage error pointing at `dirsql server` — it
does **not** start the server.
Bare `dirsql` does **not** start the server — that is `dirsql server`.

## Installation

Expand Down Expand Up @@ -47,6 +47,91 @@ dirsql "SELECT basename, size FROM './' ORDER BY size DESC LIMIT 5"
pipeline, same flags, same output. See that section for config discovery,
`--persist`, `--on-file`, hooks, and exit codes.

## The REPL

`dirsql` with no subcommand and no SQL reads statements until EOF:

```bash
dirsql
# dirsql 0.2.7 — this directory is a database.
#
# SELECT basename, size FROM './' ORDER BY size DESC LIMIT 5
# SELECT path FROM './**/*.md' WHERE content LIKE '%TODO%'
#
# `exit`, `quit`, or Ctrl-D to leave.
#
# dirsql> SELECT count(*) AS files FROM './'
# [{"files":128}]
# dirsql>
```

Statements go through the same pipeline as [`dirsql query`](#dirsql-query) and
`POST /query`, so a statement typed at the prompt and one passed on the command
line return identical rows. Every config flag the default mode takes — `-c`,
`--persist`, `--no-ignore`, `--on-file` — applies unchanged:

```bash
dirsql -c .dirsql.toml --persist
```

The index is built **once**, before the first prompt: statements share one scan
rather than re-walking the directory each time, and the live watcher keeps it
fresh between them. Files the scan had to skip are named on stderr once, up
front.

### Terminal vs. pipe

The prompt and banner appear only when **stdin is a terminal**. From a pipe or
a redirect there is neither, so results arrive alone:

```bash
printf "SELECT 1 AS n\nSELECT 2 AS n\n" | dirsql
# [{"n":1}]
# [{"n":2}]

dirsql < queries.sql > rows.jsonl
```

One statement per line, in both modes. Blank lines do nothing.

### Leaving

`exit`, `quit` (either case), or Ctrl-D. There are no dot-commands: the `.`
prefix exists in `sqlite3` to namespace meta-commands against SQL, and with no
meta-commands there is nothing to namespace. Schema questions are ordinary SQL:

```sql
SELECT name FROM sqlite_master WHERE type = 'table';
```

### Errors

A statement that fails prints its diagnostic — the same string the HTTP
`{"error": …}` body carries — to stderr, and **the session continues**. This is
the one behavioral difference from `dirsql query`, which exits `1` on the first
failure:

```
dirsql> SELECT nope FROM missing
dirsql: SQLite error: no such table: missing
dirsql> SELECT 1 AS n
[{"n":1}]
```

A config that cannot be loaded is different in kind: it fails identically for
every statement, so it is reported once and exits `1` before the first prompt.

### Exit codes

| Code | Meaning |
|---|---|
| `0` | Clean EOF (Ctrl-D, `exit`, `quit`, or the end of a piped script) — **including when statements failed**. Matches interactive `sqlite3`; use [`dirsql query`](#dirsql-query) when a script needs a statement's exit status. |
| `1` | The index could not be built (a bad `-c`, an unresolvable `--on-file`), or stdin could not be read. Nothing was executed. |

`23` (partial scan) is not produced here: skipped files are reported before the
first prompt, and a session's exit code describes the session rather than one
scan.

## `dirsql server`

```bash
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
{
"command": "uv run python -m pytest tests/e2e/ -x -q",
"ran_at": 1787258883,
"exit_code": 0,
"commit": "5eea7dd799ad6dc8ec81e2895baa86aad3f78006",
"branch": "claude/epic-953-stacked-prs-x59wm3"
}
3 changes: 3 additions & 0 deletions packages/rust/changelog.d/2026-08-20-bare-dirsql-repl.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
**Added**

- **Bare `dirsql` opens a REPL over the current directory.** With no subcommand and no SQL, dirsql reads statements until EOF instead of printing a usage error: `dirsql> ` prompts and a sample-query banner when stdin is a terminal, one statement per line and no interactive furniture when it is a pipe (`dirsql < script.sql > out.json`). Statements go through the same `execute_query` pipeline `dirsql query` and `POST /query` use, so results are identical to the one-shot form. A failing statement prints its diagnostic to stderr and the session **continues** — unlike `dirsql query`, which exits 1 — and a clean EOF exits 0 regardless of per-statement failures, matching interactive `sqlite3`. Leave with `exit`, `quit`, or Ctrl-D; there are no dot-commands. The index is built once before the loop, so statements share one scan and the live watcher keeps it fresh between them. Every config flag bare `dirsql` already accepted (`-c`, `--persist`, `--no-ignore`, `--on-file`) applies unchanged, and an unusable config is reported once up front rather than on every statement. (#987)
65 changes: 65 additions & 0 deletions packages/rust/migrations.d/2026-08-20-bare-dirsql-repl.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
### Bare `dirsql` opens a REPL instead of exiting 2 (#987)

#### Summary

`dirsql` with no subcommand and no SQL printed a usage error pointing at
`dirsql server` and exited `2`. It now reads SQL statements from stdin until
EOF — a prompted REPL on a terminal, one statement per line from a pipe. No
API changed and no flag was added or removed; the runtime behavior of one
invocation did.

#### Required changes

| Before | After | Fix |
| ------ | ----- | --- |
| `dirsql` (bare, no stdin redirect) — exit 2, usage error on stderr | Opens an interactive REPL and blocks until EOF | Nothing, if a human ran it. A **script** that ran bare `dirsql` expecting an immediate exit-2 must redirect stdin (`dirsql < /dev/null`, which now exits 0) or call the mode it meant (`dirsql query "<sql>"`, `dirsql server`). |
| `echo "SELECT 1" \| dirsql` — exit 2, the SQL ignored | Runs the statement and prints its rows | Nothing. If the exit-2 was being relied on as "this invocation is invalid", pass the SQL as an argument instead: `dirsql "SELECT 1"`. |
| `dirsql --help` / `dirsql --version` | Unchanged | — |

#### Deprecations removed

_None._

#### Behavior changes without code changes

- Bare `dirsql` no longer exits at all until stdin reaches EOF. In a
non-interactive context with an open stdin (a CI step, a supervisor that
leaves stdin attached to a pipe nothing writes to), the process now waits
where it used to fail fast.
- The exit code for bare `dirsql` changes from `2` to `0` on a clean EOF —
**including when statements failed**. Per-statement failures are reported on
stderr as they happen and do not colour the session's exit code, matching
interactive `sqlite3`. A script that needs a statement's exit status must use
`dirsql query "<sql>"`, which still exits 1 on the first failure.
- `PARTIAL_SCAN_EXIT` (23) is not produced by the REPL. Skipped files are still
named on stderr, once, before the first prompt; the code has no meaning for a
session whose exit describes the session rather than one scan. `dirsql query`
keeps it.
- `exit` and `quit` are consumed by the REPL rather than being handed to SQLite.
Neither was a valid statement before, so nothing that used to run stops
running.
- A config that fails to load (`-c missing.toml`) exits 1 before the first
prompt rather than being reported once per statement.

#### Verification

```bash
# Previously: exit 2, "dirsql: no query given…" on stderr.
echo "SELECT 1 AS n" | dirsql
# -> [{"n":1}]
# -> exit 0

# A failure no longer ends the session, and does not colour the exit code.
printf 'SELECT nope FROM missing\nSELECT 2 AS n\n' | dirsql
# -> dirsql: SQLite error: no such table: missing (stderr)
# -> [{"n":2}]
# -> exit 0

# One-shot mode is unchanged: still exits 1 on the first failure.
dirsql "SELECT nope FROM missing"; echo $?
# -> 1

# Fast-fail for a script that relied on bare `dirsql` returning immediately.
dirsql < /dev/null; echo $?
# -> 0
```
2 changes: 2 additions & 0 deletions packages/rust/src/cli/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@
//! - [`router`] — axum routes + request handlers (thin HTTP adapters).
//! - [`execute`] — the transport-agnostic query pipeline shared by the
//! HTTP handler and the one-shot `dirsql query` subcommand.
//! - [`repl`] — the read-eval-print loop behind bare `dirsql`.
//! - [`serialize`] — row + event → JSON.

use std::net::SocketAddr;
Expand All @@ -31,6 +32,7 @@ use crate::DirSQL;

pub mod execute;
pub mod init;
pub mod repl;
pub mod router;
pub mod run;
pub mod serialize;
Expand Down
Loading
Loading