Skip to content

REPL slice 1: bare dirsql opens a REPL (core loop, TTY/pipe split, exit, banner) - #991

Merged
thekevinscott merged 4 commits into
mainfrom
claude/epic-953-stacked-prs-x59wm3
Aug 21, 2026
Merged

thekevinscott merged 4 commits into
mainfrom
claude/epic-953-stacked-prs-x59wm3

Conversation

@thekevinbot

@thekevinbot thekevinbot commented Aug 20, 2026 •

Copy link
Copy Markdown
Collaborator

Fixes #987

Slice 1 of 3 under #953. Stack: this PR ← #988 ← #989.

Bare dirsql (no subcommand, no SQL) printed a usage error and exited 2. It now reads statements until EOF: a banner and a dirsql> prompt when stdin is a terminal, one statement per line and nothing else when it is a pipe. sqlite3 sets the expectation — run the binary bare, get a shell.

What's here

A new packages/rust/src/cli/repl.rs and a rewritten run_default None arm. load_state and execute_query are untouched: statements reach the pipeline through the same query_body intake a one-shot query and a POST /query do, so a statement typed at the prompt cannot drift from the same statement passed on the command line. Output is still the JSON array — the TTY table is #989.

No new dependency (std::io::IsTerminal is std since 1.70) and no new flag (ConfigArgs is already flattened onto the top-level Cli, so -c, --persist, --no-ignore and --on-file apply with no parser work).

Three behaviors are deliberately not run_query's:

  • A failing statement is reported and the session continues, where dirsql query exits 1. A REPL that dies on a typo is unusable.
  • A clean EOF exits 0 regardless of per-statement failures, matching interactive sqlite3. PARTIAL_SCAN_EXIT (23) has no session-level meaning, so skipped files are named once before the first prompt and the code is not produced.
  • A degraded index is reported once and exits 1 before the loop, rather than failing identically on every line the user types.

Two notes on the issue's plan

#987's survey of the old behavior was incomplete. It says only no_subcommand_and_no_sql_selects_neither touches it, and that it asserts parsing. packages/rust/tests/cli_e2e.rs::no_subcommand_and_no_sql_errors_pointing_at_dirsql_server also asserted the runtime exit-2. Its #662 intent — bare dirsql must not silently start the server — outlives the usage error, so it is rewritten to assert that invariant against the REPL rather than deleted.

curtaincall (real-PTY e2e) is deferred to #988, deliberately. Slice 1's interactive surface is banner / prompt-per-read / trailing newline, all covered by unit tests with injected sinks plus the manual PTY run below. Slice 2's surface — arrow-key recall, Ctrl+R, Ctrl+C mid-line, multi-line continuation — is testable no other way, so that is where the dependency earns its keep. It also keeps a new dependency out of the PR that changes CLI dispatch, which is the same reasoning #988 gives for splitting reedline out. Practical detail for whoever picks up #988: curtaincall declares requires-python >=3.12 against this package's >=3.10 floor, so it needs a python_version >= "3.12" marker plus a skip guard — and the local venv must be recreated on 3.12, or the test silently skips instead of running (upstream: thekevinscott/curtaincall#26).

One API-shape deviation

run_repl takes an err sink the issue's sketch did not: (state, input, out, err, interactive). Without it the diagnostics go through eprintln!, and neither unit lint's isolation rule nor unit mutation can reach them. The loop is likewise generic over a private Statements trait (StateStatements<'a> is the real implementation over execute_query) so its control flow is exercised with no index behind it — that trait is also the seam #988 should swap the reedline reader behind.

PARITY

No drift. The CLI is one shared implementation in the Rust core that every SDK's launcher reaches through run_cli; there is no per-SDK surface to diverge, and no SDK public API changed. PARITY.md needs no edit — stated here rather than skipped silently, per AGENTS.md.

Manual run (AGENTS.md)

cd /tmp/replmanual                     # two .md files: a.md (6 B), b.md (10 B)

printf "SELECT basename, size FROM './' ORDER BY basename\nSELECT nope FROM missing\nSELECT count(*) AS files FROM './'\nexit\nSELECT 999\n" | dirsql
# [{"basename":"a.md","size":6},{"basename":"b.md","size":10}]
# dirsql: SQLite error: no such table: missing          <- stderr; the session continues
# [{"files":2}]
# exit 0, and the statement after `exit` never runs

printf "SELECT basename FROM './' ORDER BY basename\n\nquit\n" | script -qec dirsql /dev/null
# 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> [{"basename":"a.md"},{"basename":"b.md"}]
# dirsql> dirsql>                                       <- blank line re-prompts; trailing newline on exit

script -qec dirsql /dev/null < /dev/null; echo $?      # Ctrl-D equivalent
# 0

Also observed: the unquoted-path hint (did you mean "./"?) reaches the REPL unchanged, since it comes from the shared pipeline.

E2E Verification

  • Ran e2e suites locally for every affected SDK
  • Python SDK e2e: pass (32 passed)
  • TypeScript SDK e2e: pass (16 passed)
  • Rust core e2e: pass — cargo test --features cli --tests, including the new repl_pipe_e2e (9) and the rewritten cli_e2e case (44)
  • packages/python e2e attestation refreshed (a packages/rust/src change stales both bindings)
  • packages/ts e2e attestation refreshed
  • Command(s) run:
    cargo test --manifest-path packages/rust/Cargo.toml --features cli --lib     # 700 passed
    cargo test --manifest-path packages/rust/Cargo.toml --features cli --tests
    cd packages/python && uv run python -m pytest tests/e2e/ -x -q               # 32 passed
    cd packages/ts && pnpm test:e2e                                              # 16 passed
    cd packages/python && uvx testing-conventions e2e attest 'uv run python -m pytest tests/e2e/ -x -q'
    cd packages/ts     && uvx testing-conventions e2e attest 'pnpm test:e2e'
    uvx testing-conventions e2e verify --base origin/main --scope packages/python/dirsql \
        --extra-scope packages/rust/src --extra-scope packages/python/src packages/python   # exit 0
    uvx testing-conventions e2e verify --base origin/main --scope packages/ts/src \
        --extra-scope packages/rust/src --extra-scope packages/ts/napi packages/ts          # exit 0
    
  • Result summary: all green. One pre-existing, environment-only failure in tests/vtab.rs::unreadable_file_yields_null_content_without_erroring_the_row — the sandbox runs as uid 0, so a chmod 000 file is still readable and the NULL-content assertion cannot hold. It is green in CI and unrelated to this change.

Changelog / Migrations

  • Changelog fragment: packages/rust/changelog.d/2026-08-20-bare-dirsql-repl.md
  • Migration fragment: packages/rust/migrations.d/2026-08-20-bare-dirsql-repl.md — required: runtime behavior change with no API change (bare dirsql: exit-2 → REPL; echo "SELECT 1" | dirsql now runs the statement)

Red/green

390264e pushed the failing suite alone; CI was observed red on Integration tests (run 32412502201) with 8 failures, all of them the new REPL assertions and nothing else in the crate. The implementation follows in 5eea7dd.

Other gates run locally

unit colocated-test and unit lint (rust) exit 0 · cargo clippy --workspace -- -D warnings and cargo fmt --all --check clean · unit coverage (rust, nightly) exit 0, with the actuals rising against the floors: lines 93.47 (floor 92), regions 92.82 (91), functions 92.19 (91), branch 78.36 (72).

unit mutation was run directly through cargo-mutants under a deliberately stricter scope than CI's (-- --lib, unit targets only), which surfaced three survivors that are now killed:

Survivor Fix
StateStatements::run -> Ok(Default::default()) a unit test driving it against AppState::Unavailable — the real one reports the index's state, a stub cannot
|| -> && in the prompt guard a FlushFails sink (writes succeed, flush fails), so the prompt's two failure modes are distinguishable
two TIMEOUTs on (read > 0) in next_line the EOF check is now match reader.read_line(..)? { 0 => None, _ => Some(line) } — no comparison operator, so a mutant can no longer turn EOF into an endless empty line and hang instead of failing

The two remaining run_default -> 0 / -> 1 mutants survive only under that --lib scope; CI's cargo-mutants uses the default full-cargo test scope, where repl_pipe_e2e's exit-code assertions kill them. Same shape as every previous change to run.rs's effectful arms (e.g. b2094a4), which is why the repo carries zero mutation exemptions.

claude added 4 commits August 20, 2026 20:08
Bare `dirsql` currently prints a usage error and exits 2. These tests
assert the contract #987 specifies instead: one statement per line from a
pipe, no prompt or banner, a failing statement reported and the session
continued, clean EOF exiting 0, and an unusable config failing once rather
than per line.

Pushed on its own so CI is observed red before any implementation lands.

Refs #987

skip-changelog: test-only commit; the fragment lands with the implementation

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GU6WPGs4bbkad8QhQfyXDD
`dirsql` with no subcommand and no SQL printed a usage error and exited 2.
It now reads statements until EOF: a banner and a `dirsql> ` prompt when
stdin is a terminal, one statement per line and nothing else when it is a
pipe. `sqlite3` sets the expectation -- run the binary bare, get a shell.

The loop is a wrapper around the existing pipeline, not a second one:
`load_state` and `execute_query` are unchanged, and statements reach
`execute_query` through the same `query_body` intake a one-shot query and a
`POST /query` do, so a statement typed at the prompt cannot drift from the
same statement passed on the command line.

Three behaviors are deliberately not `run_query`'s:

- a failing statement prints its diagnostic and the session continues, where
  `dirsql query` exits 1. A REPL that dies on a typo is unusable.
- a clean EOF exits 0 regardless of per-statement failures, matching
  interactive `sqlite3`. `PARTIAL_SCAN_EXIT` (23) has no session-level
  meaning, so skipped files are named once before the first prompt and the
  code is not produced.
- a degraded index is reported once and exits 1 before the loop, rather than
  failing identically on every line the user types.

The index is built once, ahead of the loop, so statements share one scan and
the live watcher keeps it fresh between them. No new dependency
(`std::io::IsTerminal` is std) and no new flag: `ConfigArgs` is already
flattened onto the top-level `Cli`, so `-c`, `--persist`, `--no-ignore` and
`--on-file` apply to the REPL with no parser work.

The read runs on a blocking thread: `run_cli` drives everything inside
`runtime.block_on`, so an inline read would park the thread the runtime is
driven on. `BufReader<Stdin>` rather than `StdinLock`, which is not `Send`.

`cli_e2e`'s `no_subcommand_and_no_sql_errors_pointing_at_dirsql_server` did
assert the old runtime error, contrary to #987's survey, which found only the
parse-level test. Its #662 intent -- bare `dirsql` must not silently start
the server -- outlives the usage error, so it is rewritten to assert that
invariant against the REPL rather than deleted.

The REPL loop takes its statement sink, reader and both output sinks as
parameters so `unit lint`'s isolation rule is satisfiable without a TTY or an
index; that is why `run_repl` carries an `err` sink #987's sketch did not.

Fixes #987

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GU6WPGs4bbkad8QhQfyXDD
@thekevinscott
thekevinscott merged commit 6f9e34a into main Aug 21, 2026
78 checks passed
@thekevinscott
thekevinscott deleted the claude/epic-953-stacked-prs-x59wm3 branch August 21, 2026 02:17
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

REPL slice 1: core loop, TTY/pipe split, exit, banner

3 participants