harlequin Documentation

The SQL IDE for Your Terminal. Now with hsql, your agent's favorite sql client.

6,478 stars Python Python 14 files · ~107,520 tokens #agent#cli#client#database#db#db-cli#db-client#duckdb
RAW Doc

Technical Documentation: tconbeer/harlequin

ℹ️ Provenance: Multi-Tier Ingestion · CodeWiki Reference

Overview (`README.md`)

Harlequin

The SQL IDE for Your Terminal.

[!TIP]
This README contains a small subset of the docs available at
harlequin.sh.

Installing Harlequin

Harlequin is a Python program, and there are many ways to install and run it. We strongly recommend using uv:

  1. Install uv. From a POSIX shell, run:

    bash
    curl -LsSf https://astral.sh/uv/install.sh | sh

    Or using Windows Powershell:

    powershell
    powershell -ExecutionPolicy ByPass -c "irm https://astral.sh/uv/install.ps1 | iex"
  2. Install Harlequin as a tool using uv:

    bash
    uv tool install harlequin

    This command will install Harlequin into an isolated environment and add it to your PATH so you can easily run the executable.

Other Installation Methods

Alternatively, if you know what you're doing, after installing Python 3.9 or above, install Harlequin using pip, pipx, poetry, or any other program that can install Python packages from PyPI:

bash
pip install harlequin

There is also a Homebrew formula for Harlequin, although this is maintained by the community and is not as rigorously tested as the Python installations. Note that the formula includes several Harlequin adapter packages (Postgres, MySQL/MariaDB, and ODBC) and their dependencies, which is convenient but increases the application size.

bash
brew install harlequin

Installing Database Adapters

Harlequin can connect to dozens of databases using adapter plug-ins. Adapters are distributed as their own Python packages that need to be installed into the same environment as Harlequin.

For a list of known adapters provided either by the Harlequin maintainers or the broader community, see the adapters page.

The adapter docs also include installation instructions. Some adapters can be installed as Harlequin extras, like postgres. If you used uv to install Harlequin:

bash
uv tool install 'harlequin[postgres]'

You can install multiple extras:

bash
uv tool install 'harlequin[postgres,mysql,s3]'

Running Harlequin

Once Harlequin is installed, you run it from the command line. The arguments and options you pass in at the command line affect Harlequin's behavior, like what database adapter it uses, which database it connects to, whether or not the file picker is visible, and more. Assuming you have installed Harlequin so that it is on your PATH (uv tool install harlequin does this automatically), you run Harlequin by typing a command of this form into your shell:

bash
harlequin [OPTIONS] [CONN_STR]

where [OPTIONS] is 0 or more pairs of the form --[option-name] [option-value], and [CONN_STR] is 0 or more connection strings. [OPTIONS] are composed of both Harlequin options and adapter options. For a full list of options, run Harlequin with the --help option:

bash
harlequin --help

Using Harlequin with DuckDB

Harlequin defaults to using its DuckDB database adapter, which ships with Harlequin and includes the full DuckDB in-process database.

To open an in-memory DuckDB session, run Harlequin with no arguments:

bash
harlequin

To open one or more DuckDB database files, pass in relative or absolute paths as connection strings (Harlequin will create DuckDB databases if they do not exist):

bash
harlequin "path/to/duck.db" "another_duck.db"

Using Harlequin with SQLite and Other Adapters

Harlequin also ships with a SQLite3 adapter. To use that adapter, you specify the --adapter sqlite option. Like DuckDB, you can open an in-memory SQLite database by omitting the connection string:

bash
harlequin --adapter sqlite

You can open one or more SQLite database files by passing in their paths as connection strings; note that the --adapter option has a short alias, -a:

bash
harlequin -a sqlite "path/to/sqlite.db" "another_sqlite.db"

Other adapters can be installed as plug-ins; for more information, see the installation guide, and the guides for individual adapters. Each adapter can define its own options, which you can view using harlequin --help.

Configuring Harlequin

Harlequin contains a large number of options that allow you to set the theme, customize key bindings, show remote and local files, set the locale for number formatting, and much more. These can always be entered at the command line, but it can be convenient to define a configuration as a profile instead. For more information on configuring Harlequin, see Using Config Files.

A profile's string values can name environment variables — password = "${MYPASSWORD}", or ${MYHOST:-localhost} to supply a default — so a config file your team shares holds no credentials, and values an adapter declares as secrets are masked wherever Harlequin prints them. hsql, Harlequin's companion CLI (below), reads the same files and can report on them, with hsql --config show, --config validate and --config schema.

Using Harlequin with Agents or in Scripts

Harlequin has a companion CLI, hsql, that uses the same adapters and config files, but is optimized for headless use by agents and in automations. hsql is packaged with Harlequin (no additional installation required):

bash
$ hsql -P dev -c "select * from users"
 id | name
----+---------
 1  | Ted
 2  | Patrick
(2 rows)

hsql can also explore the catalog without writing SQL: hsql --catalog --path mydb.analytics lists a schema's relations (and --path mydb.analytics.orders lists that table's columns), while hsql --catalog-search customer_id searches every level at once.

To bound what an agent can do, --read-only connects in a mode the database refuses writes in, and hsql --timeout 30 cancels a run that takes longer than that. Both are also profile keys, and both refuse to start if the adapter cannot enforce them. Harlequin takes --read-only too.

For more information on hsql, see the getting started docs.

Using Harlequin with Django

django-harlequin provides a command to launch Harlequin using Django’s database configuration, like:

bash
./manage.py harlequin

Keep Reading at [harlequin.sh](https://harlequin.sh/docs/getting-started/usage)

Visit harlequin.sh for an overview of features and full documentation, starting with a guided walkthrough of how to edit and execute queries, use the data catalog, export data, and more.

Getting Help

To view all command-line options for Harlequin and all installed adapters, after installation, simply type:

bash
harlequin --help

To view a subset of these docs (and a link back here) from within the app, press F1.

See the Troubleshooting guide for help with key bindings, appearance issues, copy-paste, etc.

GitHub Discussions are a good place to ask questions, request features, and say hello.

GitHub Issues are the best place to report bugs.

Sponsoring Harlequin

Please consider sponsoring Harlequin's author, so he can continue to dedicate time to Harlequin.

Contributing

Thanks for your interest in Harlequin! Harlequin is primarily maintained by Ted Conbeer, but he welcomes all contributions!

Please see CONTRIBUTING.md for more information.


Overview (`packaging/hsql/README.md`)

hsql

hsql is your agent's favorite SQL client. It's the headless CLI for
Harlequin, and shares the same config and query engine,
with an interface optimized for agents, scripts, and automations.

[!TIP]
This README contains a small subset of the docs available at
harlequin.sh.

If you already use Harlequin and know about adapters and config files, jump ahead to Running hsql.

Installing hsql

hsql is packaged with Harlequin, so if you already use Harlequin,
hsql is already installed.

Otherwise, you can install hsql directly. hsql is a Python program, and there are many ways to install and run it. We strongly recommend using uv:

  1. Install uv. From a POSIX shell, run:

    bash
    curl -LsSf https://astral.sh/uv/install.sh | sh

    Or using Windows Powershell:

    powershell
    powershell -ExecutionPolicy ByPass -c "irm https://astral.sh/uv/install.ps1 | iex"
  2. Install hsql as a tool using uv:

    bash
    uv tool install hsql

    This command will install hsql into an isolated environment and add it to your PATH so you can easily run the executable.

Installing Database Adapters

hsql can connect to dozens of databases using adapter plug-ins. Adapters are distributed as their own Python packages that need to be installed into the same environment as hsql and harlequin.

For a list of known adapters provided either by the Harlequin maintainers or the broader community, see the adapters page.

The adapter docs also include installation instructions (installing an adapter with Harlequin also installs it for hsql). Some adapters can be installed as Harlequin extras, like postgres. If you used uv to install hsql directly, you can add adapter packages using --with:

bash
uv tool install hsql --with harlequin-postgres

You can install multiple extras:

bash
uv tool install hsql --with harlequin-postgres --with harlequin-mysql

Running hsql

Once hsql is installed, you run it from the command line. If you have used psql or the duckdb CLI, hsql will feel familiar, but hsql has the major advantage that is works with most databases and provides the same interface and produces the same output, regardless of the connected database. This means you (and your agent) can learn one tool, instead of several. In your shell, all hsql commands take the same form:

bash
hsql [OPTIONS] [CONN_STR]

where [OPTIONS] is 0 or more pairs of the form --[option-name] [option-value], and [CONN_STR] is 0 or more connection strings. [OPTIONS] are composed of both hsql options and adapter options. For a full list of options, run hsql with the --help option:

bash
hsql --help

Using hsql with DuckDB

hsql defaults to using its DuckDB database adapter, which ships with hsql and includes the full DuckDB in-process database.

Run a query against an in-memory DuckDB session, run hsql and pass in a query with the -c option:

bash
$ hsql -c "select 1"
 1
---
 1
(1 row)

To query one or more DuckDB database files, pass in relative or absolute paths as connection strings (hsql will create DuckDB databases if they do not exist):

bash
$ hsql "path/to/duck.db" -tAc "select count(*) from orders"
42

Using hsql with SQLite and Other Adapters

hsql also ships with a SQLite3 adapter. To use that adapter, you specify the --adapter sqlite option. Like DuckDB, you can open an in-memory SQLite database by omitting the connection string:

bash
$ hsql --adapter sqlite -c "select 'Ted' as author"
 author
--------
 Ted
(1 row)

You can query one or more SQLite database files by passing in their paths as connection strings; note that the --adapter option has a short alias, -a:

bash
$ hsql -a sqlite "path/to/sqlite.db" -c "select * from users"
 id | name
----+---------
 1  | Ted
 2  | Patrick
(2 rows)

Other adapters work the same way; for example, Postgres:

bash
$ hsql -a postgres "postgresql://example.com/postgres:5432" -c "select * from invoices"

[!TIP]
You should use Profiles to keep credentials out of your shell
history. For more information, keep reading or see
the docs on
config files.

Configuring hsql and Using Profiles

hsql supports a number of options for setting the query limit, configuring output formats, and defining connection parameters. Options can be passed as command-line flags, or read from config files. Config files store configurations under separate profiles, so you can easily switch between databases by reading from different profiles with the -P option:

bash
$ hsql -P prod -c "select count(*) from orders" --csv
$ hsql -P dev -c "select * from users" --vertical --limit 5
$ hsql -P warehouse -c "..." --format parquet -o invoices.pq

hsql reads the same config files as Harlequin, and merges them one profile at a time, nearest file first; pass --config-path PATH to read a single file instead, or -P None to skip them entirely.

Keeping Credentials Out of Config Files

A profile's string values can name environment variables, so a config file your team shares — and commits — holds no credentials:

toml
[profiles.prod]
adapter = "postgres"
host = "${MYHOST:-localhost}"  # ${VAR:-default} supplies a default
password = "${MYPASSWORD}"     # ${VAR} is required; hsql exits 2 if it is unset

Write $${ for a literal ${, if a value really does start with one.

Values an adapter declares as secrets are masked wherever hsql prints them — --config show, --info, and error messages — as is the password inside a connection string.

Inspecting and Writing Config Files

Five --config modes work on your config files instead of running SQL, and none of them connects to a database:

  • list-profiles — the names you can pass to -P, with each one's adapter, and which is the default.
  • show — the merged config as TOML, with the file each value came from beside it; --json for JSON.
  • validate — every problem in every discovered file, exiting 2 if it finds any.
  • schema — a JSON Schema for a config file, covering every adapter you have installed; point your editor at it for completion and validation as you type.
  • init — a profile written from the options you pass, prompting for nothing: hsql --config init -P prod -a sqlite ./my.db --limit -1 writes [profiles.prod] into the nearest config file.
bash
$ hsql --config show
default_profile = "dev" # from /home/user/.config/harlequin/harlequin.toml

[profiles.dev] # from /home/user/proj/harlequin.toml, overriding /home/user/.config/harlequin/harlequin.toml
adapter = "postgres"
host = "${MYHOST:-localhost}"
password = "********"
limit = 100

list-profiles and validate are result sets, so --csv, -t, -A and -o work on them as they do on a query.

Data Layouts and File Formats

hsql supports all of the following formats for displaying and writing data:

  • table
  • markdown (alias: md)
  • vertical
  • csv
  • tsv
  • json
  • jsonl (alias: ndjson)
  • parquet
  • orc
  • feather (alias: arrow)
  • none (suppresses output)

You can select a format with the --format <name> or using the shorthand --<name>, so these are equivalent: --format csv, --csv.

Some layouts can present the results from multiple queries. Others will raise an error and exit with code 2 if multiple queries are executed.

Additionally, for any layout, pass --stats to print summary info as JSON to stderr:

bash
$ hsql -c "select 1" --format none  --stats
{"status":"ok","statements":1,"rows":1,"truncated":false,"limit":500,"elapsed_ms":1,"columns":[{"name":"1","type":"#"}]}

Exploring the Catalog

Before you can write a query, you have to know what is in the database. --catalog lists the objects one level below --path, and exits without running SQL:

bash
$ hsql "path/to/duck.db" --catalog
 path | name | query_name | type     | type_label
------+------+------------+----------+------------
 duck | duck | "duck"     | database | db
(1 row)

Every row's path is what lists that object's own children, so you walk down one level at a time:

bash
$ hsql "path/to/duck.db" --catalog --path duck
 path           | name      | query_name         | type   | type_label
----------------+-----------+--------------------+--------+------------
 duck.analytics | analytics | "duck"."analytics" | schema | sch
 duck.main      | main      | "duck"."main"      | schema | sch
(2 rows)

$ hsql "path/to/duck.db" --catalog --path duck.analytics
 path                        | name         | query_name                 | type       | type_label
-----------------------------+--------------+----------------------------+------------+------------
 duck.analytics.customers    | customers    | "analytics"."customers"    | BASE TABLE | t
 duck.analytics.order_totals | order_totals | "analytics"."order_totals" | VIEW       | v
 duck.analytics.orders       | orders       | "analytics"."orders"       | BASE TABLE | t
(3 rows)

$ hsql "path/to/duck.db" --catalog --path duck.analytics.orders
 path                              | name        | query_name    | type          | type_label
-----------------------------------+-------------+---------------+---------------+------------
 duck.analytics.orders.customer_id | customer_id | "customer_id" | BIGINT        | ##
 duck.analytics.orders.id          | id          | "id"          | BIGINT        | ##
 duck.analytics.orders.placed_at   | placed_at   | "placed_at"   | TIMESTAMP     | ts
 duck.analytics.orders.total       | total       | "total"       | DECIMAL(18,2) | #.#
(4 rows)

A listing is rows, so every format and output option applies to it, exactly as they do to a query:

bash
$ hsql "path/to/duck.db" --catalog --path duck.analytics -tA --csv
duck.analytics.customers,customers,"""analytics"".""customers""",BASE TABLE,t
duck.analytics.order_totals,order_totals,"""analytics"".""order_totals""",VIEW,v
duck.analytics.orders,orders,"""analytics"".""orders""",BASE TABLE,t

Searching the Catalog

Walking is the wrong tool for where does orders live and which tables have a customer_id. --catalog-search TERM searches every level of the catalog at once, for objects whose name contains TERM:

bash
$ hsql "path/to/duck.db" --catalog-search customer_id
 path                                    | name        | query_name    | type   | type_label
-----------------------------------------+-------------+---------------+--------+------------
 duck.analytics.order_totals.customer_id | customer_id | "customer_id" | BIGINT | ##
 duck.analytics.orders.customer_id       | customer_id | "customer_id" | BIGINT | ##
 duck.main.staging_events.customer_id    | customer_id | "customer_id" | BIGINT | ##
(3 rows)

--path narrows the search to one subtree, and the output can be laid out like any query:

bash
$ hsql "path/to/duck.db" --catalog-search order --path duck.analytics -tA
duck.analytics.order_totals|order_totals|"analytics"."order_totals"|VIEW|v
duck.analytics.orders|orders|"analytics"."orders"|BASE TABLE|t

Note: not all adapters support catalog search at this time.

Scripting with hsql

[!WARNING]
To make hsql safe and efficient for agents, by default hsql applies a 500-row
limit to all queries. To remove this limit, use --limit -1 or set
limit = -1 in your profile. If limits truncate data, hsql will print
a warning on stderr; we recommend that you do NOT suppress or redirect
that message so do NOT use hsql with 2>/dev/null.

hsql can write data to files, either with the -o option or by piping output (hsql only writes data to stdout; other messages go to stderr):

bash
$ hsql -P prod --limit -1 -c "select * from users" --format parquet -o "users.pq"
$ hsql -P prod --limit -1 -c "select * from users" --csv > users.csv

hsql can execute multiple statements in one invocation, and supports several methods for doing so:

  • Pass -c multiple times
  • Include multiple queries, separated by ;, in one -c option
  • Pass one or more .sql files with -f, with multiple statements in each
  • Use --result to define which queries output data to stdout
  • Use --on-error to either stop or continue if one or more queries produces an error.

In other words, this works:

bash
$ hsql -P prod --limit -1 --format md --result all --on-error stop \
    -f ./setup.sql \
    -c "select count(*) from raw_table" \
    -f ./build-models.sql \
    -c "select count(*) from modeled_table"

hsql's exit codes are meaningful and stable:

  • 0: Success
  • 1: Query error
  • 2: Usage/config error
  • 3: Connection error
  • 4: Timeout
  • 130: Interrupted

You can also use --stats and jq together to error on a truncated query:

bash
hsql --limit -1 -c "select 1" --csv -o data.csv --stats 2>&1 | jq -e '.truncated | not' > /dev/null

[!TIP]
A script or agent that runs many invocations against the same database can
hold one connection open instead of reconnecting for each one.
hsql --serve dev -P dev runs a session named dev, and
hsql --session dev -c "..." (or HSQL_SESSION=dev) sends invocations to
it, which answer in milliseconds. A session is state rather than a cache:
temp tables, settings and open transactions survive from one invocation to
the next. POSIX only; see
warm sessions.

Running Safely

Two options bound what an invocation can do, and hsql refuses to run at all if the adapter cannot enforce them.

--read-only (or -r) connects in a mode the database itself refuses writes in:

bash
$ hsql -r "path/to/duck.db" -c "insert into orders values (1)"
hsql: error: Invalid Input Error: Cannot execute statement of type "INSERT" on database "duck" which is attached in read-only mode!

--timeout SECONDS limits the duration of executing and fetching a query, and exits 4 when it runs out:

bash
$ hsql --timeout 0.5 -c "select count(*) from range(100000000000) t(i)"
hsql: error: timed out after 0.5s

Both are also profile keys:

toml
[profiles.agent]
adapter = "postgres"
read_only = true
timeout = 30

hsql refuses to open a connection with an adapter that cannot enforce --read-only or --timeout.

hsql --info reports implements_read_only and implements_cancel for every installed adapter.

Describing hsql to an Agent

hsql --help is written for a person; two flags answer the same questions as JSON, so an agent can find its way around an installation it has never seen. --spec is a machine-readable --help: every option on the command, plus the connection options every installed adapter declares, each with its type, choices, default and help text. --info describes the installation instead — versions, platform, the config files hsql found, the profile that would be active, and what each installed adapter declares it supports — so a script can check that an adapter is there, and what it can do, before depending on it.

bash
$ hsql --info -a sqlite | jq '.adapters.sqlite'
{
  "distribution": "harlequin",
  "version": "2.10.0",
  "capabilities": {
    "implements_cancel": true,
    "implements_catalog_search": true,
    "implements_read_only": true,
    "implements_validate_sql": false
  },
  "error": null
}

Neither connects to a database, both write JSON to stdout, and -a NAME narrows either one to a single adapter, which is much faster than importing them all.

Installing the Agent Skill

hsql ships an Agent Skill that teaches an agent how to drive it: ask before you assume, keep credentials off the command line, read the catalog before writing SQL, consider a warm session, watch the 500-row limit, branch on the exit code, and hand off to harlequin when a human should drive. hsql --skill installs it, with no network and matched to the version you have:

bash
$ hsql --skill -o ~/.claude/skills/hsql/          # for you, in every project
$ hsql --skill -o .claude/skills/hsql/            # for this repo, committed with it
note: wrote 6 files to .claude/skills/hsql: SKILL.md, references/config.md, references/queries.md, references/scripting.md, references/sessions.md, references/troubleshooting.md

With no -o, hsql --skill writes SKILL.md to stdout, so you can read it before you install it.

In Claude Code, the same skill is a plugin in Harlequin's repository:

text
/plugin marketplace add tconbeer/harlequin
/plugin install hsql@harlequin

Keep Reading at [harlequin.sh](https://harlequin.sh/docs/getting-started/hsql)

Visit harlequin.sh for an overview of features and full documentation.

Getting Help

To view all command-line options for Harlequin and all installed adapters, after installation, simply type:

bash
hsql --help

GitHub Discussions are a good place to ask questions, request features, and say hello.

GitHub Issues are the best place to report bugs.

Sponsoring Harlequin and hsql

Please consider sponsoring Harlequin's author, so he can continue to dedicate time to hsql.

Contributing

Thanks for your interest in Harlequin! Harlequin and hsql are primarily maintained by Ted Conbeer, but he welcomes all contributions!

Please see CONTRIBUTING.md for more information.

Differences from psql

-c, -f, -t, -A and -x mean what they mean in psql, so hsql -tAc "select count(*) from orders" prints a bare number, exactly as it would there. These do not:

psql hsql
-P --pset, an output setting --profile, a config-file profile
Field separator -F --csv, --format tsv, or any other --format
Listing databases -l --catalog
Describing an object \d, \dt --catalog --path, --catalog-search
Stopping on the first error -v ON_ERROR_STOP=1 --on-error stop, which is the default
One transaction -1 write begin and commit in your script
-o a file for query output a file, or a directory that gets one file per result set
Connection flags -h, -p, -U, built in the adapter's, so hsql --help -a postgres lists them
Row limits none 500 rows by default; --limit -1 removes it
Suppressing chatter -q nothing to suppress: stdout is only ever results
Exit codes 1 its own error, 2 connection, 3 script error 1 query error, 2 usage/config, 3 connection, 4 timeout

Overview (`tests/skill_evals/README.md`)

Skill trigger evals

trigger_queries.json is the eval set for the hsql skill's description, which is the
only part of a skill always in context and the whole of what decides whether it loads.

Run it with the description-optimization loop in the skill-creator skill, which needs
the claude CLI. It is a pre-merge exercise on a change to the description, not a CI
check: scoring it costs model calls, and the answer is a rate rather than a pass or fail.

Ten positives and ten negatives, split train/test so a description tuned on one half is
scored on turns it was never tuned against. The positives include turns that never say
"hsql" or "harlequin", because under-triggering is the failure mode; the negatives are
near misses — SQL-shaped work that is not a query, and database-shaped work that belongs
to another tool.

It lives here rather than beside the skill because
src/harlequin/hsql/skill/ is what ships in the wheel, and is the directory a
marketplace entry points at as a plugin.

Last run

2026-08-28, against the description as it ships: 10/10 positives and 10/10 negatives on
both splits. The description was not tuned against this set — it scored at ceiling on the
first pass — so the set's value now is as a regression check on the next edit to the
description, and it will need harder near misses before it can distinguish one good
description from another.


Overview (`tests/data/unit_tests/completions/README.md`)

The completions were generated from this database:

python
from sklearn.datasets import load_iris
import pandas as pd
import duckdb

# Load the iris dataset
iris = load_iris()
iris_df = pd.DataFrame(data=iris.data, columns=iris.feature_names)
iris_df["target"] = iris.target
iris_df["species"] = pd.Categorical.from_codes(iris.target, iris.target_names)

# Connect to DuckDB (this will create a new database if it doesn't exist)
con = duckdb.connect("iris.db")

# Create and insert data into the table
con.execute("""
    CREATE TABLE IF NOT EXISTS iris (
        sepal_length FLOAT,
        sepal_width FLOAT,
        petal_length FLOAT,
        petal_width FLOAT,
        target INTEGER,
        species VARCHAR
    )
""")

# Insert the data
con.execute("INSERT INTO iris SELECT * FROM iris_df")

# Verify the data (optional)
result = con.execute("SELECT * FROM iris LIMIT 5").fetchall()
print("First 5 rows:", result)

# Close the connection
con.close()

Essentially the database was created and then sunk the data in the completions
to json using dataclasses.asdict and json.dumps. Proceeded with some manual
deletion to make the file smaller.


Cli Options (`docs/cli-options.md`)

CLI options: the groups, and how to add one

Harlequin and hsql share their option machinery, and hsql additionally
sorts every option into a group. This document is the reference for that,
and the checklist for adding an option.

Where an option comes from

There are two kinds, and they are declared in different places.

An adapter's options are AbstractOption subclasses in the adapter's own
ADAPTER_OPTIONS list. Declaring one gets all three renderings —
to_click() for the CLI, to_widgets() for the TUI, to_questionary() for
the config wizard — and both commands attach them dynamically once the first
pass has named the adapter. Nothing in core lists them.

A command's own options are CoreOption declarations in
harlequin/core_options.py, one list for both commands. Each says which
command takes it (harlequin=On(...), hsql=On(...), or both), how that
command spells it, and the flags that decide where its value may come from —
group, cli_only, and first_pass, which is whether the pass that names
the adapter reads it before there is a command to parse with.
attach_core_options() builds the click options from that list, in the order
it declares them, which is the order hsql --help lists them; the part only a
command can answer — a click.Choice of what is installed, a callback, help
naming a computed default — stands in the declaration as a RuntimeValue. These are the
frozen part of each command's surface; an adapter option whose spelling
collides with one loses that spelling (first_pass.attach_adapter_options).

Every option that is not CONN_STR is also a profile key, under the name
click gives it (--read-only is read_only). config.py validates a profile
against the command's parameters plus the adapter's declared options, so a key
neither declares is an error.

The five groups

hsql runs in two roles — a server (--serve NAME) and a client
(--session NAME) — and an option belongs to exactly one group, decided by
when its value is read. An option hsql declares cannot be declared without
one; the frozensets hsql refuses against are derived from the declarations.

Group Read --serve Served request
Group.CONNECTION once, when the connection opens accepted compared against the session's; equal is served, differing exits 2
Group.PER_REQUEST on every invocation refused, exit 2 accepted
Group.SERVER once, at start-up; only a server has one accepted refused, exit 2
Group.ROLE to pick which process runs the invocation — —
Group.CONFIG to pick the file and profile the rest come from accepted accepted

Two of these need a word.

Group.CONFIG (-P, --config-path) names where values come from rather
than being values
, so which group a profile belongs to is decided by what it
holds. A profile of nothing but format and limit is per-request whichever
way it was named; one that names a database is compared like a typed flag.

Every adapter option is a connection option. connection_option_names()
is the join: CONNECTION_OPTIONS plus the adapter's declared names. It is a
positive test — a key that is neither hsql's nor the adapter's, such as one
of TUI_ONLY_KEYS or a misspelling in a profile, describes no connection and
is not compared.

Adding an option

  1. Decide whose it is. If it changes how a database is reached, it is an
    adapter option — declare it in that adapter's ADAPTER_OPTIONS and stop.
    Everything below is for a core option on harlequin or hsql.

  2. Write the CoreOption in harlequin/core_options.py, among the ones it
    belongs with — where it sits in that list is where hsql --help lists it.
    Give it harlequin=On(), hsql=On(), or both, and put in kwargs what
    both commands share; an On's own kwargs merge over those, and its
    decls replace them, which is for a difference the two commands really
    have. For hsql, keep the help one or two sentences and name the default.

  3. Give it a group, by when its value is read:

    • once, at connect time → Group.CONNECTION
    • on each invocation, including every mode → Group.PER_REQUEST
    • once, for a server that is up → Group.SERVER

    An option hsql takes has to have one, and an option only the IDE takes has
    to have none —
    tests/unit_tests/test_hsql_serve.py::test_every_option_is_in_exactly_one_group
    pins the groups to the command they are refused against.

  4. If it is a mode — one that reports rather than running SQL — also add
    it to _one_mode(), so two modes in one invocation are refused. A mode that
    reports on the installation or the config files declares first_pass=True
    and goes in the needs_profile / needs_adapter predicates in
    build_cli() too, so the first pass reads no profile for it; a mode that is
    configured like a run does not (--catalog, --history).

  5. If a config file may not set it, mark it cli_only=True. That is for
    options whose value decides which process runs the invocation, or which a
    config file discovered in the working directory should not be able to
    weaken; CLI_ONLY_SESSION_KEYS and CLI_ONLY_SSH_KEYS, which are what
    refuse it, are derived from the flag.

  6. An option the IDE reads and hsql must not is one with no hsql=:
    TUI_ONLY_KEYS is every declaration harlequin takes and hsql does not.

  7. Regenerate the committed artifacts, which are pinned by tests:

    bash
    uv run python scripts/write_config_schema.py
    uv run python scripts/write_cli_reference.py
  8. Add a CHANGELOG.md entry under [Unreleased] if the option is
    user-facing.

What the partition test still catches

Group membership travels with the declaration, so the two cannot drift. What
the test reads is the built command against the derived sets, which is what
catches an option added to harlequin/hsql/cli.py as a bare @click.option
rather than declared — it would reach the command in no group at all, and
nobody would refuse it. It cannot catch a profile key that no command
declares; that class is handled instead by connection_option_names() being a
positive test.


Generated Hsql Reference (`docs/generated/hsql-reference.md`)

hsql

Execute SQL and exit.

CONN_STR: one or more connection strings, or paths to local db files.

Generated from the bare hsql command, with no adapters loaded. An adapter
contributes its own options on top of these: hsql --help -a NAME shows one
adapter's, and hsql --spec reports every installed adapter's as JSON.

Usage

text
hsql [OPTIONS] [CONN_STR]...

Arguments

Argument Type Accepts
CONN_STR text zero or more

Options

Alphabetical by name. Flags are off by default.

Option Type Values Default Env var Description
-a, --adapter choice NAME duckdb The name of an installed database adapter plug-in to use to connect to the database at CONN_STR.
--catalog boolean List the catalog objects one level below --path, and exit without running SQL.
--catalog-search text TERM Search the whole catalog, at every level, for objects whose name contains TERM, and exit without running SQL. Not every adapter can; see --info.
--color choice auto, always, never never Color text output. auto follows the terminal and NO_COLOR.
-c, --command text Execute SQL. Repeatable.
--config choice show, list-profiles, validate, schema, init Report on the config files hsql found, or write a profile into one, and exit without running SQL. One of: show, list-profiles, validate, schema, init.
--config-path path PATH HARLEQUIN_CONFIG_PATH By default, Harlequin finds files named .harlequin.toml in the current directory and the home directory (~) and merges them. Use this option to specify the full path to a config file at a different location.
--csv boolean Shorthand for --format csv.
--display-rows integer N Rows printed per result set by the text layouts. -1 for all rows. [default: 40 for table, markdown, md; 10 for vertical]
-f, --file text PATH Execute SQL from a file, or from stdin for -. Repeatable.
--format choice table, markdown, md, vertical, csv, tsv, json, jsonl, ndjson, parquet, orc, feather, arrow, none table Output format. See below for the list.
--help boolean Show this message and exit.
--history boolean List the queries harlequin and hsql have run, newest first, and exit without running SQL. --limit says how many; -P, -a or a CONN_STR narrows it to one database.
--history-search text TERM List the logged queries whose SQL contains TERM, newest first, and exit without running SQL. Scoped like --history.
--idle-timeout number SECONDS 1800.0 With --serve: stop the session once it has gone SECONDS with no request. 0 for a session that waits as long as it takes.
--info boolean Versions, config files, the active profile, and what each installed adapter declares it supports, as JSON. Connects to nothing. -a narrows it to one adapter.
--json boolean Shorthand for --format json.
--jsonl boolean Shorthand for --format jsonl.
--limit integer N 500 Maximum rows fetched per result set. -1 for no limit.
--markdown boolean Shorthand for --format markdown.
--max-lifetime number SECONDS 28800.0 With --serve: stop the session SECONDS after it connected, whatever it is doing; a request already running finishes first. 0 for a session that runs until something stops it.
-A, --no-align boolean Unaligned output. As in psql.
--no-footer boolean Omit the row-count footer, keeping other chrome.
--no-header boolean Omit the header row, keeping other chrome.
--no-write-history boolean Do not record this run's queries in the query history that Harlequin and hsql share.
--null-string text TEXT Render NULL as TEXT. Defaults to NULL for text formats, empty for csv.
--on-error choice stop, continue stop What to do when a statement fails.
-o, --output path PATH Write results to PATH instead of stdout. Accepts a file or directory.
--path text TEXT Where in the catalog --catalog looks, and what --catalog-search searches under. Dotted segments, named by the adapter; the top of the catalog by default. A trailing * filters a --catalog listing.
-P, --profile text Select a profile from an available config file to load its values. Other options passed here will take precedence over those loaded from the profile. Use the special profile named None to use Harlequin's defaults, instead of the default profile specified in the config file.
--queue-timeout number SECONDS With --serve: a request waits at most SECONDS for the one before it, then exits 4 without reaching the database. [default: no limit]
-r, --read-only boolean Connect read-only, and refuse to run at all if the adapter cannot. To check an adapter's capabilities, use --info.
--result text all|last|N all Which result set(s) to emit.
--serve text NAME Connect, then hold the connection open as the session named NAME and answer --session NAME invocations from it until stopped. Takes connection and session-lifetime options; no per-request ones. Not on native Windows.
--session text NAME Send this invocation to the running session named NAME, started with --serve. HSQL_SESSION=NAME does the same for every invocation, and runs without the session, with a warning, when none is up.
--session-reset boolean Ask the session to close its connection and open a fresh one, and exit without running SQL. Temp tables, settings and an open transaction are gone. Needs --session.
--session-status boolean Poll the server for its status as JSON, and exit. Reports while a query is running. Needs --session.
--skill boolean Write the Agent Skill for driving hsql, as markdown. -o installs it: 'hsql --skill -o ~/.claude/skills/hsql/'.
--spec boolean Every option here, plus every installed adapter's, as JSON. -a narrows it to one adapter.
--ssh-allow-reuse boolean When the local port is already bound, warn and connect through the listener that has it instead of failing.
--ssh-batch-mode boolean Fail rather than prompt for a passphrase, a password or a host key. ssh's own BatchMode.
--ssh-forward text TEXT A local forward, spelled as ssh -L takes one: LOCAL:HOST:REMOTE. Repeat this option for more than one. Omit it when your ssh config already has the LocalForward.
--ssh-host text TEXT Open an SSH tunnel to this destination first, and connect through it. A Host alias, host, user@host or ssh://user@host:port, passed to ssh verbatim.
--ssh-timeout number SECONDS Seconds to wait for the tunnel's forwards. Default is 60.
--stats boolean Write a one-line JSON summary to stderr.
--timeout number SECONDS Cancel the run after SECONDS and exit 4. Refused if the adapter cannot cancel a query; to check, use --info.
-t, --tuples-only boolean Rows only: no header, no footer. As in psql.
--version boolean Show the version and exit.
-x, --vertical boolean Shorthand for --format vertical. As in psql.

-a/--adapter names an installed adapter plug-in. hsql --info reports
adapters installed in your environment.

Formats

--format takes one of these names, which affect the layout of data in stdout.
Modify output further with options above (e.g., -t, -A, --display-rows).
none runs the SQL but generates no output. The suffix is what -o DIRECTORY
names a file with.

Format Kind Suffix Holds several result sets
table text layout .txt yes
markdown text layout .md yes
md text layout .md yes
vertical text layout .txt yes
csv file .csv no
tsv file .tsv no
json file .json no
jsonl file .jsonl yes
ndjson file .ndjson yes
parquet file .parquet no
orc file .orc no
feather file .feather no
arrow file .arrow no
none writes nothing yes

Exit codes

Code Meaning
0 Success.
1 The database rejected the SQL.
2 A bad flag, a bad profile, or a config file hsql could not read.
3 hsql could not connect.
4 --timeout ran out, and hsql stopped the run.
70 hsql hit a bug in itself and wrote a crash report.
130 Interrupted.

Plans Crash Recovery Design (`docs/plans/crash-recovery-design.md`)

Crash recovery and bug reporting

The design behind #687: never dump a raw
traceback at a user, preserve their work when something goes wrong, and make the report they send us
worth reading.

The problem

An uncaught exception on Harlequin's message pump falls straight through AppBase._handle_exception
(app_base.py:68) into Textual's _fatal_error(), which renders a rich.traceback.Traceback
with show_locals=True and prints it to stderr. That is intimidating, it tells the user nothing
about what to do next, and it puts local variables — connection strings, tokens, query results — on
the terminal. Replacing it is a privacy fix as much as a UX one.

hsql has the same hole from the other end. main() (hsql/__init__.py) catches ClickException,
Abort and KeyboardInterrupt; anything else escapes to Python's own handler, which prints a
traceback and exits 1 — and 1 is ExitCode.QUERY, so a bug in Harlequin is indistinguishable from
"the database rejected the SQL." A caller scripting against hsql's exit codes cannot tell the two
apart.

Three problems compound it:

  • The user's work is lost. write_editor_cache is called from exactly one place,
    Harlequin.action_quit() (app.py:970). A crash, a kill -9, a closed terminal or a dropped ssh
    session loses every open buffer — as does the self.exit(return_code=2) path.
  • The reports we get are thin. bug_report.md asks for version, adapter, shell, terminal, OS,
    locale and install method as free text, so most reports arrive missing half of it.
  • A crash at startup could repeat forever. #745
    crashed inside EditorCollection.on_mount while replaying cached buffers. Saving buffers on
    crash without a guard turns a one-time crash into a crash loop, which is worse than what we have
    now.

Outcome: a crash exits cleanly with a short panel naming a crash report file; the user's buffers
survive and come back on the next launch; a crash while restoring those buffers cannot repeat; and
the report drops into an issue template built to receive it.

The two ideas that carry it

A per-process recovery file, separate from the shared cache. Checkpoints go to
recovery-.pickle, never to cache-1.pickle. Two Harlequins on one machine keep today's
comprehensible "last clean quit wins" semantics instead of thrashing a shared file every minute, and
a checkpoint can never overwrite what another process wrote when it quit cleanly.

Rename before replay. os.replace the recovery file to <name>.replayed before unpickling and
replaying it. If unpickling or replaying crashes, the next start finds nothing to replay. That is the
whole crash-loop guard — one line, no session sentinel, no PID-liveness check, and correct across
concurrent processes by construction.

Where the crash message goes. Textual's panic() calls _close_messages_no_wait(), so the
message pump is gone the moment we handle the exception: an in-app modal is unreachable from
_handle_exception, and attempting one re-enters an app that has already proven it is broken. The
message is a rich Panel at _exit_renderables[0], printed to stderr after the terminal is
restored — the same mechanism AppBase.__init__ already uses for a bad theme.

`editor_cache.py`

python
CHECKPOINT_INTERVAL_SECONDS = 60.0
RECOVERY_STALE_SECONDS = 3 * CHECKPOINT_INTERVAL_SECONDS

def write_cache(cache: Cache) -> bool          # now atomic, and never raises
def get_recovery_file() -> Path                # cache dir / f"recovery-{os.getpid()}.pickle"
def write_recovery(cache: Cache) -> bool
def clear_recovery() -> None
def adopt_recovery() -> tuple[Cache | None, Path | None]

CACHE_VERSION stays 1 — the pickled shape does not change.

  • Atomic write. Both writers go through _write_pickle(cache, path): a temp file in the same
    directory (carrying the pid, so a killed process leaves at most one bounded stray),
    flush() + os.fsync(), then os.replace(). Same-directory means a same-volume rename, atomic on
    POSIX and on Windows. The fsync is what stops a power loss leaving a zero-length file.
  • Never raises. Returns False on OSError / pickle.PicklingError. Both callers run where an
    exception would itself take the app down. On Windows os.replace raises PermissionError when
    another process holds the destination open, so a checkpoint has to be allowed to skip silently.
  • adopt_recovery() globs recovered-*.pickle (written by the crash handler) newest first, then
    recovery-*.pickle whose mtime is older than RECOVERY_STALE_SECONDS — that age test is how a
    live sibling process's file is left alone, without a PID-liveness check that would be wrong on
    Windows and wrong under PID reuse. It renames the chosen file to .replayed before unpickling
    it, and returns (cache, replayed_path) so the app can say where the buffers came from.
    Everything wrapped → (None, None).

Derive get_recovery_file() from the same user_cache_dir call as get_cache_file(), so
tests/functional_tests/test_cache.py:43's existing monkeypatch covers the new files for free.

Checkpointing (`app.py`)

  • on_mount: self.set_interval(CHECKPOINT_INTERVAL_SECONDS, self._checkpoint_editor_cache).
  • _checkpoint_editor_cache():
    • bail when getattr(self, "editor_collection", None) is None or not mounted — the collection is
      created in compose(), not __init__;
    • build Cache(focus_index=..., buffers=...) on the message pump (the buffers property queries
      widgets — code_editor.py:294 calls _save_loaded_buffer() first);
    • bail when every buffer is blank. A startup crash gives buffers == [], and writing that over
      a good recovery file destroys exactly the work this feature exists to save;
    • bail when cache == self._last_checkpointed_cache. Cache/BufferState are dataclasses and
      Selection is a NamedTuple, so == is an exact content comparison — no dirty flag to keep in
      sync across tab swaps;
    • write_recovery(cache), synchronously. Pickling a few SQL buffers is microseconds; a thread
      worker would add ordering hazards (two checkpoints racing; exclusive=True cancelling one
      mid-flight while its thread still completes the write) for no measured win.
  • action_quit() keeps its write_editor_cache(...) and then calls clear_recovery().

Restoring (`components/code_editor.py`)

EditorCollection.__init__ keeps self.startup_cache = load_cache() and adds
self.recovered_cache, self.recovered_from = adopt_recovery(). Two separate module-level names,
not folded into load_cache(), so tests/functional_tests/conftest.py's no_use_buffer_cache
fixture stays the one seam.

on_mount prefers recovered_cache over startup_cache when present — a recovered session started
from the cache, so it is strictly newer — and then notifies: "Recovered buffers from a session that
ended unexpectedly."
The shared cache-1.pickle is left untouched, so if the recovered content is
itself the problem, the last clean-quit state is still on disk.

`environment.py` (new, headless)

Facts about the running installation, shared by hsql --info, the crash report, and (later) the
Debug Info screen. No textual, no rich; adapter_facts defers harlequin.plugins into its body as
info._adapters already does.

python
def harlequin_version() -> str
def python_facts() -> dict[str, str]        # version, implementation, executable
def platform_facts() -> dict[str, str]      # system, release, machine
def terminal_facts() -> dict[str, Any]      # term, term_program(+version), colorterm, shell,
                                            # wsl_distro, ssh (bool), locale, size
def install_facts() -> dict[str, Any]       # installer, in_venv
def adapter_facts(only: str | None = None) -> dict[str, dict[str, Any]]
def runtime_report() -> dict[str, Any]
  • terminal_facts() reports ssh: True/False and never the value of SSH_CONNECTION, which
    holds IP addresses. size from shutil.get_terminal_size().
  • install_facts() reads distribution("harlequin").read_text("INSTALLER") (pip/uv/pipx write it)
    and sys.prefix != sys.base_prefix — that answers the bug template's install-method checkbox
    automatically.
  • adapter_facts is info._adapters + info._capabilities moved verbatim, minus the
    diagnostics.note() call, which a shared module cannot make.

hsql/modes/info.py then calls these instead of building the facts itself, and keeps its stderr note
by walking adapter_facts()'s result for entries with an error. Acceptance criterion:
hsql --info's JSON is byte-identical.
terminal_facts and install_facts are not added to
that document — it is an agent-facing contract with a generated reference pinned by
test_cli_reference.py, and TERM means nothing to a non-interactive program.

Add harlequin.environment and harlequin.crash to the source_modules of the "adapter API is
reachable without the TUI" import-linter contract, and to HEADLESS_IMPORTS in
tests/unit_tests/test_import_hygiene.py. Both have to stay headless anyway, because hsql imports
them.

`crash.py` (new, headless)

python
CRASH_REPORT_KEEP = 10
ISSUE_URL = "https://github.com/tconbeer/harlequin/issues/new?template=crash_report.md"

def get_crash_report_dir() -> Path
def root_cause(error: BaseException) -> BaseException
def build_crash_report(error: BaseException, context: Mapping[str, Any],
                       program: str = "harlequin") -> str
def write_crash_report(report: str) -> Path | None
def crash_message(report_path: Path | None, error: BaseException, saved: bool,
                  program: str = "harlequin") -> str
  • Location. platformdirs.user_log_path(appname="harlequin", appauthor=False) —
    ~/Library/Logs/harlequin on macOS, ~/.local/state/harlequin/log on Linux,
    %LOCALAPPDATA%\harlequin\Logs on Windows. Not the cache dir: a cache is what a user is told to
    delete when things go wrong, and this is the file we are asking them to keep. Match config.py's
    appauthor=False — editor_cache/catalog_cache omit it and so nest twice on Windows; don't copy
    that.
  • root_cause unwraps a Textual WorkerFailed via getattr(error, "error", None) rather than
    importing it, so this module stays headless. It matters: database_tree.py:498 (_loader) and
    code_editor.py:88 (read_symbols) are the two @work decorators in src/ that omit
    exit_on_error=False, so they do reach _handle_exception, wrapped. A report naming
    WorkerFailed is a useless report.
  • Content, in order: a header telling the user to review the file before sharing and linking
    ISSUE_URL; runtime_report(); the context the caller supplied; traceback.format_exception
    for the root cause and, when different, the wrapper — no show_locals; and last, under a
    heading that says what it is, the SQL in the active buffer.
  • Plain text, not markdown, written as crash-{utc:%Y%m%dT%H%M%SZ}-{pid}.log. The user pastes
    this into a fenced block in an issue, and a markdown report's own ``` fences would break out of
    it. Sections are delimited with plain === SECTION === rules for the same reason.
  • The active buffer's SQL is included deliberately. It is usually what is needed to reproduce a
    TUI bug, the file is local, and the user chooses whether to paste it. The tradeoff is real — SQL
    holds table names and literals — so it goes last, under an explicit heading, and both the panel and
    the issue template say to review before sharing. Only the active buffer; the rest live in the
    recovery file.
  • Redaction. The whole assembled string goes through redact.redact_text() once at the end — one
    choke point, matching hsql/diagnostics.py::_write's discipline.
  • Pruning. Newest CRASH_REPORT_KEEP kept. Distinct filenames per crash, so a loop cannot
    destroy the evidence. write_crash_report returns None and raises nothing when the directory is
    unwritable.

exception.py gains class HarlequinCrashError(HarlequinError): pass, so the panel is
pretty_error_message(HarlequinCrashError(crash_message(...), title="Harlequin crashed.")) — no new
rendering code, rich stays lazily imported, and it looks like every other Harlequin panel.

The handler (`app_base.py`)

Two hooks with working base implementations, so keys_app.py gets crash reports too:

python
def _save_work_on_crash(self) -> bool:
    """Persist anything the user would lose. False if there was nothing to save."""
    return False


def _crash_context(self) -> dict[str, Any]:
    """What this app was doing, for the crash report."""
    return {}


def _handle_exception(self, error: Exception) -> None:
    if self._exit or self._crash_handled:
        return
    self._crash_handled = True

    saved = False
    try:
        saved = self._save_work_on_crash()
    except BaseException:
        pass

    report_path = None
    try:
        report = build_crash_report(error, self._crash_context())
        report_path = write_crash_report(report)
    except BaseException:
        pass

    try:
        self.bell()
        self._return_code = 1
        # run_test re-raises from these
        if self._exception is None:
            self._exception = error
            self._exception_event.set()
        message = crash_message(report_path, error, saved)
        panel = HarlequinCrashError(message, title="Harlequin crashed.")
        self.panic(pretty_error_message(panel))
    except BaseException:
        # last resort: the user gets *something*
        super()._handle_exception(error)
        return

    # textual run --dev, i.e. `make serve`: append the full traceback at index 1
    if "debug" in self.features:
        super()._handle_exception(error)

/* Detailed source-code truncated for AI context efficiency. */

python
CRASH = 70
"""hsql hit a bug in itself. `sysexits.h`'s EX_SOFTWARE."""

70 rather than the next free small integer: sysexits.h reserves it for an internal software error,
and it cannot be confused with the 1–4 hsql already documents or with 130.

exit_code_for() does not change. It maps caught failures, and an unrecognized error caught
while running a query genuinely is a query failure — that is what QUERY means. CRASH belongs only
at the boundary where nothing else caught the error at all.

main() (hsql/__init__.py) gains a final handler, after the ClickException and
Abort/KeyboardInterrupt clauses:

python
except BaseException as e:
    report_path = write_crash_report(build_crash_report(e, _crash_context(argv), program="hsql"))
    diagnostics.report_crash(report_path)
    sys.exit(ExitCode.CRASH)

/* Detailed source-code truncated for AI context efficiency. */

markdown
---
name: Crash Report
about: Harlequin or hsql exited unexpectedly and wrote a crash report.
title: 'Crash: '
labels: 'bug'
assignees: ''

---
**Before proceeding, please acknowledge**:
- [ ] I have searched Issues and Discussions in this repo for this error.

**Paste your crash report below.**

Harlequin and `hsql` write a crash report and print its path when they exit
unexpectedly. If you still have that message on screen, the path is in it.
Otherwise, the reports are here:

| OS | Location |
|---|---|
| macOS | `~/Library/Logs/harlequin/` |
| Linux | `~/.local/state/harlequin/log/` |
| Windows | `%LOCALAPPDATA%\harlequin\Logs\` |

The newest `crash-*.log` is the one you want.

> [!IMPORTANT]
> Please read the report before pasting it. It contains your configuration
> (with passwords masked) and the SQL that was in your active buffer.


Crash report

PASTE YOUR CRASH REPORT HERE

text
**What were you doing when it crashed?**
Steps to reproduce it, if you know them.

**Anything else?**
Screenshots, or anything the report doesn't cover.

**Contributing**
Are you interested in contributing a fix?
- [ ] Yes
- [ ] Maybe
- [ ] No

bug_report.md gains one line under its acknowledgements pointing here: "Did Harlequin exit with an
error and print a crash report path? Use the Crash Report template instead."

Ordered commits

Each is independently green under make check.

  1. write_cache atomic and non-raising. editor_cache.py only; no behavior change visible to the app.
  2. Recovery file + rename-before-replay. editor_cache.py, code_editor.py, app.py's
    clear_recovery, conftest patches. Nothing writes a recovery file yet, so this is provably inert
    on the happy path.
  3. The checkpoint timer — commit 2's machinery gets a producer.
  4. environment.py + the info.py refactor. Acceptance: hsql --info byte-identical. Import-linter
    contract and HEADLESS_IMPORTS in the same commit.
  5. crash.py, HarlequinCrashError, AppBase._handle_exception, Harlequin's two hooks,
    hide_secrets_in, ctx.exit(return_code), and the test_cli.py fixture fix.
  6. ExitCode.CRASH, diagnostics.report_crash, the main() handler, hsql's _crash_context.
  7. .github/ISSUE_TEMPLATE/crash_report.md and the bug_report.md pointer.

Test infrastructure — the landmine

run_test's re-raise path goes through _handle_exception, so every failing functional test in
the suite would write a crash report into the developer's real user_log_dir.
Add to
tests/conftest.py, autouse and session-wide with no marker opt-out, in the spirit of the existing
no_discovered_config:

python
@pytest.fixture(autouse=True)
def crash_reports_go_to_tmp(monkeypatch, tmp_path) -> Path:
    """Every crash a test causes writes here, not into the developer's log dir."""
    report_dir = tmp_path / "crash-reports"
    monkeypatch.setattr("harlequin.crash.get_crash_report_dir", lambda: report_dir)
    return report_dir

Extend tests/functional_tests/conftest.py::no_use_buffer_cache in the same commit as the
checkpoint timer: patch harlequin.components.code_editor.adopt_recovery → lambda: (None, None)
and harlequin.app.write_recovery / clear_recovery → no-ops. Keep calling the existing
harlequin.app.write_editor_cache name so the current patch keeps working.

tests/unit_tests/test_cli.py::mock_harlequin is MagicMock(spec=Harlequin), so tui.return_code
is a MagicMock and ctx.exit(mock) makes res.exit_code a MagicMock — about fifteen existing
assertions break.
Set mock_harlequin.return_value.return_code = 0 in the fixture, and add a test
that return_code = 1 produces res.exit_code == 1.

Tests to add

tests/unit_tests/test_editor_cache.py (new): no *.tmp left behind; a pickle.dump that
raises leaves the previous cache byte-identical and returns False; an unwritable directory returns
False; adopt_recovery renames to .replayed before returning (the crash-loop guarantee); it
ignores a fresh recovery-<pid>.pickle and adopts a stale one; it always adopts recovered-*.

tests/unit_tests/test_crash.py (new): the report names the exception type, message, version and
frames; it prints no secret — hide_secrets_in({"conn_str": ["postgres://u:hunter2-and-more@h/db"]}),
raise an error quoting the DSN, assert the secret is absent and ******** present, mirroring
test_the_debug_screen_prints_no_secret; the report contains no ``` fence, so it pastes into one;
root_cause unwraps a WorkerFailed; write_crash_report prunes to CRASH_REPORT_KEEP and returns
None on PermissionError.

tests/functional_tests/test_crash.py (new), injecting through _handle_exception directly:

python
with pytest.raises(RuntimeError):
    async with app.run_test() as pilot:
        ...
        app._handle_exception(RuntimeError("boom"))
        await pilot.pause()

Assert app.return_code == 1; one crash file in the redirected dir; a recovery file holding the
buffer text (@pytest.mark.use_cache); "boom" and the report path in the rendered
_exit_renderables[0]; "Traceback (most recent call last)" not in it. Plus: two exceptions in a
row produce one report and no recursion; a write_crash_report that raises still exits 1 with a
panel; Harlequin(...) constructed but never run returns False from _save_work_on_crash without
raising (crash before mount); a blank-buffer checkpoint leaves a good recovery file unchanged; and
the crash loop — plant a recovered-*.pickle, make action_new_buffer raise, run and crash, then run
a second app and assert one empty buffer and the poisoned file now .replayed.

tests/unit_tests/test_hsql.py: a callback that raises exits ExitCode.CRASH (70) and not
QUERY; the stderr line names the report path; a DSN passed as CONN_STR does not appear in a
report written before hide_secrets_in has run.

No snapshot churn. Nothing renders in-app except the recovery toast, which is a standard Textual
notification; the crash message reaches stderr after the app is gone.

Verification

bash
localedef -i en_US -f UTF-8 en_US.UTF-8   # fresh container only
make check                                 # ruff, pytest 3.10 + 3.12, mypy
uv run lint-imports
uv run pytest tests/unit_tests/test_import_hygiene.py tests/unit_tests/test_crash.py \
              tests/unit_tests/test_editor_cache.py
uv run pytest tests/functional_tests/test_crash.py tests/functional_tests/test_cache.py -n0

By hand:

  1. uv run harlequin f1.db, type SQL, wait past the checkpoint interval, kill -9, restart — the
    SQL is back and the toast says where it came from.
  2. Temporarily raise from a key handler and run without dev mode (uv run harlequin f1.db, not
    make serve): the panel prints instead of a traceback, echo $? is 1, and the report at the
    named path holds the traceback. Repeat with a profile carrying a password and confirm it is masked
    and that no local variables appear.
  3. Temporarily raise from EditorCollection.on_mount, crash, remove the raise, restart twice: the
    second start is clean and the poisoned file is .replayed.
  4. make serve and crash: the full traceback still prints for developers.
  5. Temporarily raise from an hsql mode: echo $? is 70, the stderr line names the report, and a DSN
    on the command line is masked in it.
  6. Paste a real report into the crash report template's fenced block on a scratch issue and confirm
    it renders as one block.

Known risks

  • Partial-replay convergence. If a crash happens partway through replaying recovered buffers,
    _save_work_on_crash may write a new recovered-*.pickle holding the subset that loaded. The next
    start replays fewer buffers, so it converges and terminates — and salvages what it can — but it can
    take more than one restart.
  • Behavior change: both commands now exit non-zero where they exited 0. harlequin returns 1 on a
    crash and 2 on a config or connection error; hsql returns 70 where it previously let Python exit
    1. Correct, but user-visible, so both get a changelog line.
  • The report names sys.executable, the config path and SHELL, all of which reveal a username —
    and all of which hsql --info already prints, so this is consistent rather than new.

Deliberately out of scope

  • _handle_worker_error swallows unknown worker errors
    (#1117). app.py:576 branches on two worker
    names with no else, so any other worker error vanishes silently. The fix needs its own triage of
    which workers can fail benignly, so it is filed separately.
  • Debug Info screen work — environment rows and a "copy a bug report" binding. Cheap once
    environment.py exists, but it is the only piece with snapshot churn: a visible version or Python
    row bakes 2.12.2 and 3.10.14 into Debug Info Screen.svg, which then fails on every CI Python
    and after every release. If picked up, put the rows in a collapsed Collapsible (collapsed
    children never reach the SVG) and back them with a non-snapshot unit test. Drive-by finding for
    whoever does: DebugInfoScreen.move_focus() queries #collapsible-client-adapter-details, which
    AdapterDebugInfo.parse_info() never produces — dead code with a latent NoMatches.
  • Narrowing hsql's broad except Exception handlers so an internal bug caught inside cli.py
    reports CRASH rather than QUERY.
  • A prefilled GitHub issue URL (the ~8KB URL cap cannot carry a traceback) and converting the
    templates into YAML issue forms.
  • A harlequin --debug flag: the report already holds the full traceback, and TEXTUAL=debug /
    make serve already prints it inline.

Plans Harlequin For Agents (`docs/plans/harlequin-for-agents.md`)

Harlequin for Agents

A product plan for making Harlequin the best SQL client for coding agents.

Status: draft for discussion. Product and user stories only — implementation details are
deliberately sketched, not specified.

Scope: the harlequin package (this repo) and the harlequin-web site
(https://harlequin.sh).


1. The opportunity

Today an agent asked to "look at my warehouse" reaches for whatever CLI is nearest:
psql, duckdb, bq, databricks sql, mysql, trino. Each has different flags,
different output formats, different auth, different error semantics, and different
ideas about what "give me the schema" means. The agent burns tokens rediscovering all
of that on every task, and it hallucinates flags for the ones it has seen least.

Harlequin already has the thing that fixes this, and nobody else does:

  • One uniform adapter interface across ~15 databases — DuckDB, SQLite, Postgres,
    MySQL, BigQuery, Trino, Databricks, ODBC, ADBC, Cassandra, NebulaGraph, RisingWave,
    Wherobots, Exasol.
  • A normalized catalog abstraction (Catalog / CatalogItem) that already answers
    "what tables exist and what are their column types" without dialect-specific
    information_schema queries.
  • Profiles in a config file that already encode "how to connect to my warehouse,"
    discovered automatically from the cwd and home directory.
  • sqlfmt as an existing dependency.
  • A TUI on the other end of the same config, which no other CLI has.

The pitch writes itself: hsql -P prod -c "select 1" works the same way against every
database your team uses.
One CLI contract, one set of flags, one output format
vocabulary, one exit-code scheme — and when the agent needs a human, the human opens
Harlequin against the same profile and sees the same catalog.

That last point is the real differentiator. Every other SQL client can be made
agent-friendly. Only Harlequin can make the handoff good.

2. Who we are designing for

Three personas, all of which are "an agent," but with materially different needs.

P1 — Agent as operator

Runs queries headlessly on a human's behalf, usually in a loop, usually inside a coding
agent's Bash tool. Cares about: deterministic output, honest truncation, exit codes,
token-efficient results, not accidentally dropping a table.

P2 — Agent as pair

Works alongside a human who has Harlequin open. Drafts a query the human will run;
reads what the human already ran. Cares about: handoff in both directions.

P3 — Agent as support engineer

Helps a human install, configure, and troubleshoot Harlequin — "why won't it connect to
Snowflake," "add a prod profile to my config," "what does this error mean." Cares about:
machine-readable docs, a formal config spec, a self-describing CLI, good diagnostics.

Most existing "AI-friendly CLI" work only serves P1. Serving P2 and P3 is where
Harlequin can be uniquely good.

3. Design principles

These are the calls I'd want made up front, because they resolve most of the small
decisions downstream.

  1. One engine, two front doors. harlequin (TUI) and hsql (headless) share
    adapters, config discovery, profiles, limit semantics, and one execution core.
    Neither reimplements the other. See §4.

  2. stdout is data; stderr is narration. Results go to stdout and nothing else does.
    Truncation notices, warnings, errors, and --stats go to stderr — and nothing that
    stdout already carries: the row count is the result's own footer, and a timing is a
    field of --stats. This is already the intent in exception.py — make it a hard,
    tested contract.

  3. Exit codes are an API. Documented, stable, and distinct enough that an agent can
    branch on them without parsing text.

  4. Determinism over cleverness: identical output whether stdout is a TTY, a pipe, or
    a pty.
    Agent harnesses vary in which they give you. A client that formats
    differently under a pipe is a bug factory. No TTY-sniffing for format selection.
    (Color is the one exception, and it follows NO_COLOR / --color.)

  5. Token efficiency is a feature. Results cost the agent money. Defaults should be
    small, and truncation must always be announced — never silent.

  6. Be boring and psql-shaped. An agent's prior is psql, secondarily duckdb.
    Every flag we can borrow, we borrow. Novel spellings cost tokens and cause retries.

  7. Never make the agent guess. Everything Harlequin knows about itself — options
    (including plugin-contributed ones), profiles, formats, adapters, capabilities,
    catalog — should be dumpable as JSON on demand. This is the one place we should be
    more capable than psql, because our option surface is dynamic and plugin-driven, so
    no amount of pretraining can teach an agent what's actually installed.

  8. Safety is a product feature, not a warning in the docs. --read-only,
    --timeout, and --dry-run are what let a human hand an agent a warehouse
    credential without flinching.

  9. No LLM inside Harlequin. No API keys, no model config, no vendor coupling. We
    make Harlequin excellent for the agent already sitting next to the user. (See §10 for
    how this answers issue #952.)


4. Shape of the CLI: two commands, one engine

Decision: headless mode ships as a separate command, hsql, not as flags on
harlequin.

The tempting reason — it frees the flag namespace — is the weakest one. The real reasons
are these.

Two audiences want two stability contracts. The TUI's promise is "delight a human,
evolve freely." The headless promise is "output format and exit codes are an API,
frozen." Inside one command those pull against each other on every release, and a TUI
change can silently break a machine contract. A separate command makes the boundary
structural rather than something a reviewer has to remember.

Cold start stops being a discipline problem. With one entry point, "never import
Textual on the headless path" is a rule any contributor can regress silently. With a
separate module graph it becomes an import-linter test in CI — the hsql module graph
must not contain textual
— which is the difference between hitting the 300ms target
and hoping to.

It opens a lean install later. pip install hsql with no Textual, no pyperclip, is
plausible for CI images and agent sandboxes, and hard to reach from a single entry point.

What actually ships

The same package, with a second console script:
hsql = "harlequin.hsql:main" alongside the existing harlequin script.

That is the key de-risking move. The command split is cheap and near-reversible; the
package split is the expensive part, and it can wait indefinitely
— possibly forever.
We get the boundary and the enforceable import graph now, and decide about a separate
distribution only if the lean-install demand materializes.

harlequin itself gains nothing: no -c, no growth in an already-large --help,
no behavior change. The one addition is a custom unknown-option handler, so
harlequin -c "select 1" fails with "-c isn't a harlequin option — did you mean
hsql -c? See harlequin.sh/docs/headless."

Everything in Workstream B lands as hsql subcommands: hsql catalog,
hsql describe, hsql fmt, hsql spec, hsql info, hsql config, hsql history,
hsql mcp, hsql open. Bare hsql with no arguments prints help; it never launches
the TUI.

Amended in M2's plan: these are mode options, not subcommands. hsql --catalog,
hsql --info, hsql --spec, hsql --config show. It matches harlequin --config, so
one shape covers both commands — and it sidesteps the objection this section raises
against subcommands, which applies here too: hsql takes CONN_STR positionally, so
hsql catalog and a database file named catalog would have needed a disambiguation
rule, docs for the rule, and a test for the rule. No verb, no rule.

Consequences

  • The -f collision disappears, and with it the plan's only breaking change.
    harlequin -f stays --show-files forever; hsql -f is --file, as psql users
    expect. Nothing here needs to wait for 3.0 — the whole plan becomes additive and can
    ship in 2.x minors.
  • hsql gets clean access to -c, -f, -o, -A, and friends, so psql muscle memory
    works without compromise.
  • hsql --help omits every TUI-only option (--theme, --keymap-name, --show-files,
    --show-s3, --locale, --config, --keys), which is a real token saving for an
    agent reading it. It also omits the adapter connection-option matrix: hsql --help
    lists the names of installed adapters and points at hsql --help -a <adapter> for
    one adapter's options. That keeps the first thing an agent reads small and stable, and
    keeps --help true for every adapter rather than showing whichever one is the
    default.
  • The handoff gets a natural verb: hsql open query.sql reads much better than
    harlequin --new-buffer query.sql.

Alternatives rejected

  • Flags on harlequin. Requires the 3.0 -f break, grows an already-large help
    surface, and leaves cold start and the stability boundary as matters of discipline.
  • Subcommands on harlequin with a bare-invocation TUI fallback
    (harlequin query -c ...). Needs a custom Click Group that falls back to the TUI
    when the first argument isn't a known subcommand, and it is genuinely ambiguous: a
    DuckDB file named catalog or config in the cwd resolves as a subcommand. Low
    probability, but exactly the confusing failure we don't want an agent hitting.

On the name

hsql is free on PyPI as of this writing — M0 in §11 secures it before anything else
starts. HSQLDB ships sqltool and java -jar, not a binary named hsql, so there's no
PATH collision — the cost is search-results mindshare with
HyperSQL, which is real but mild and fading. hq is taken. harlequin-cli is free but
too long for something an agent types a hundred times in a task; short names are cheap
in tokens and cheap in working memory.

I'd take hsql. It's obviously "harlequin sql," and it sharpens the positioning rather
than diluting it:

Harlequin — the SQL IDE for your terminal.
hsql — the same engine, headless, for scripts and agents.
Fifteen databases, one adapter ecosystem.

That's a better story than "one tool with a flag."


5. Workstream A — `hsql`, headless execution

Closes #524. This is the
foundation; everything else in this plan assumes it.

Proposed surface

text
hsql [OPTIONS] [CONN_STR]...        Execute SQL and exit
hsql <SUBCOMMAND> [OPTIONS]         catalog, describe, fmt, spec, info,
                                    config, history, open, mcp

  -c, --command TEXT     Execute SQL. Repeatable.
  -f, --file PATH        Execute SQL from a file (or `-` for stdin). Repeatable.
  -o, --output PATH      Write results to PATH instead of stdout.
      --format NAME      Output format (see below). Default: table.
      --csv/--json/--jsonl/--markdown/--vertical    Format shorthands.
  -t, --tuples-only      Rows only: no header, no footer. As in psql.
  -A, --no-align         Unaligned output. As in psql.
      --no-header        Omit the header row (keep other chrome).
      --null-string STR  Render NULL as STR. Default: empty for csv, "NULL" for table.
  -P, --profile NAME     Same profiles as the TUI.
      --limit N          Max rows fetched per result set. Default: 500. -1 = no limit.
      --result all|last|N  Which result set(s) to emit. Default: all.
      --on-error stop|continue    Default: stop.
      --stats            Write a one-line JSON summary to stderr.
      --color auto|always|never   Default: never.
      --timeout SECONDS  Cancel the query and exit non-zero.
      --read-only        Refuse to run if the adapter can't enforce read-only.
      --dry-run          Parse/validate (and EXPLAIN where supported) without executing.
      --single-transaction        Wrap the whole script; roll back on any error.

Config discovery, profiles, and precedence are identical to the TUI: CLI > env >
--config-path > cwd config > user config dir > home > defaults. That's the whole
point — hsql -P prod -c ... means the agent never handles a credential.

Formats

Format Use
table (default) Aligned text. Human-legible, deterministic.
markdown / md Pipe table. The most reliable format for an LLM to read back.
csv, tsv Pipelines, spreadsheets.
json Array of row objects. Matches duckdb -json.
jsonl / ndjson One object per line. Streaming, large results.
vertical psql \x. One column per line — best for inspecting a single wide row.
parquet, arrow, orc Bulk handoff. Already implemented in export.py.
none Discard rows; report status only. For DDL/DML/ETL.

Amended in implementation. There is no -F: that is psql's
--field-separator, and a flag that sets a delimiter in one command and picks a
format in the other is a mistake waiting for a script — the same reasoning that
keeps -l off --limit. The format is chosen by --format spelled out, or by
the --csv/--json/--jsonl/--markdown/--vertical shorthands, which cover
the choices anyone types often enough to want a short flag for.

Behaviors that matter more than the flag list

Truncation is always announced. If --limit bites, stderr gets
note: results truncated at --limit 500; pass --limit -1 for all rows, and text formats
append a visible … 500 of >500 rows footer.

Note that --limit here is a hard limit — it caps what leaves the database, via the
adapter's set_limit(). The TUI's --limit is a soft display cap: it fetches everything
and caps what loads into the viewer, which is why the TUI can report an exact
Showing 100,000 of 3,412,887. hsql deliberately doesn't buy that number, so it says
>500; only --limit -1 yields an exact count. The TUI's default of 100,000 is
right for a TUI and a catastrophe for an agent; hsql defaults to 500.

Amended by #1026. One key
naming both of those limits was the bug, not the design: the TUI's display cap is now
viewer_max_rows, limit is the hard fetch limit in both commands, and unlimited is
spelled -1 rather than 0, so that --limit 0 can fetch a header and no rows (and
there is no -l, which is psql's --list). The text
layouts additionally cap what they print (--display-rows, 40 rows or 10 records),
since fetching 500 rows and printing 500 rows are separate questions.

Detecting truncation at all requires fetching limit + 1 rows and emitting limit —
set_limit(n) followed by fetchall() returns at most n rows and cannot say whether an
n+1th existed. Worth stating here rather than leaving to implementation, because it's the
kind of detail that gets optimized away and quietly breaks this promise.

The stderr notice fires even under -t. -t suppresses stdout chrome, not
warnings; a flag that silently defeats truncation reporting would undo principle 5.

Row counts and timing go to stderr, so hsql -c "select 1" --csv > out.csv produces
a clean file while the agent still sees 1 row in 0.02s.

--stats emits one line of JSON to stderr for any output format:

json
{"status":"ok","statements":1,"rows":500,"truncated":true,"limit":500,
 "elapsed_ms":412,"columns":[{"name":"id","type":"BIGINT"}]}

This is how an agent gets structured metadata without polluting stdout, and it works
identically whether stdout is CSV or Parquet.

Errors are plain. No Rich panels, no ANSI, no box drawing:
hsql: error: relation "usres" does not exist on stderr. The panel is a TUI affordance.

Amended in M2's plan, on two of the safety flags above.

--timeout has to attribute the cancellation itself. A cancelled DuckDB query comes
back as an empty result set with no error — fetchall() swallows the interrupt and
returns None, which is also what an empty result looks like — so a timeout that trusts
the adapter prints "no rows" and exits 0. hsql knows it fired the deadline, discards
that result, and exits 4. It also refuses up front on an adapter that can't cancel,
since there is otherwise no way to stop the work.

--single-transaction is cut too. Adapters name their transaction modes freely —
SQLite and Postgres offer "Auto"/"Manual", DuckDB overrides nothing and reports none, and
a third-party adapter may name its modes anything and default to whatever its driver
does — so core cannot ask for "the manual one" through that interface without first
reworking what a transaction mode is. Story A6's guarantee comes from begin and
commit in the caller's own script until then.

--dry-run is cut, from M2 and possibly for good. Only DuckDB implements
validate_sql at all, and there it is a parse check rather than an existence check:
select * from no_such_table validates clean, because the adapter treats every
non-parser error as valid so that DDL passes. Making it honest would mean a PREPARE
pass in the DuckDB adapter plus a refusal path on every other adapter — a lot of
machinery for a flag that would be truthful on one backend.

Exit codes:

Code Meaning
0 Success
1 Query error (the database rejected the SQL)
2 Usage / config error (bad flag, bad profile, bad TOML)
3 Connection error (couldn't reach or authenticate to the database)
4 Timeout / cancelled
130 SIGINT

Cold start is a feature. An agent may run twenty queries in a task. Target:
hsql -c "select 1" against DuckDB in under 300ms, tracked as a benchmark in CI and
protected by the import-linter rule from §4.

Large results stream — in M5, not M1. Streaming has to start at the cursor:
HarlequinCursor.fetchall() materializes the whole result before any writer exists, so
no amount of care in the format layer helps. Deferred wholesale to M5 along with
#875, rather than half-designed
now.

User stories

  • A1. As an agent, I run hsql -P prod -c "select count(*) from orders" and get a
    small, aligned result on stdout and exit code 0, so I can report a number to the human
    without parsing a TUI.
  • A2. As an agent, I run the same command against Postgres, BigQuery, and DuckDB and
    the flags, output shape, and exit codes are identical, so I don't relearn a CLI per
    database.
  • A3. As an agent, when my SQL is wrong I get a one-line error on stderr and exit
    code 1, and nothing on stdout, so I can distinguish "the query failed" from "the query
    returned zero rows."
  • A4. As an agent, when a result is larger than the limit I am told so explicitly, so
    I never report a truncated aggregate as a complete one.
  • A5. As an agent, I pass --json and pipe to jq without stripping banners,
    timings, or a "(500 rows)" footer.
  • A6. As an agent, I run a multi-statement script with -f setup.sql --single-transaction and either all of it applies or none of it does.
  • A7. As a human, I set read_only = true in my agent profile so the agent
    physically cannot write to prod, and I can point at that line in the config when
    someone asks whether it's safe.
  • A8. As a human, I use hsql in a Makefile or CI job because it's the same engine
    and the same profile I already use interactively.

On `-t`, and the `-tAc` idiom

-t is --theme in harlequin and "tuples only" in psql. Since hsql has no themes,
-t is free, and it takes the psql meaning.

The reason isn't -t in isolation — it's that psql -tAc "select count(*)" is a single
well-worn idiom, the standard way to capture a scalar in a shell script. Supporting -t
and -A separately but not together would be pointless; refusing -t would break the
cluster and make first contact with a scripting audience an error message. Borrowing the
flag is principle 6 doing its job.

The residual risk is small and worth naming: someone with harlequin -t nord habits
types hsql -t nord -c ..., -t parses as a boolean, and nord is read as a
connection string. The resulting "could not open database 'nord'" is diagnosable, and we
can special-case the hint when an unparseable conn_str matches a known theme name.

hsql -tAc "select count(*) from orders" returning a bare number and nothing else
should be an explicitly tested case, and the first example in the scripting docs.

A short "differences from psql" table in the docs should still cover -P (profile here,
pset there) and anything else that diverges.


6. Workstream B — Self-description and introspection

If Workstream A is what lets an agent run a query, this is what lets it write a
correct one on the first try. All of it lands as hsql subcommands.

`hsql catalog` — the catalog is a filesystem, not a document

The highest-leverage feature in this plan after -c. Harlequin already normalizes the
catalog across every adapter; today that value is locked inside the TUI.

The binding constraint is fetch cost, not token cost. get_catalog() issues one
query and returns databases only; every level below it is a separate fetch_children()
round trip, with columns as the leaf. Listing one schema's columns is 1 + 1 + 1 + T
queries. A "dump the tree, then filter" interface is an N+1 walk that gets slower the
more useful it would be — and #1007 already moved the TUI off eager loading for exactly
this reason. Filtering in the client protects the agent's context and does nothing for
the database.

So the model is a filesystem: ls, stat, find — not dump-and-grep. That bounds
cost structurally, because you only pay for the level you asked for, and it rides on the
navigation loop agents are most practiced at.

text
hsql catalog [PATH] [--depth N] [--max-nodes N] [--format compact|json|md|tree]
hsql describe <PATH>
hsql find <TERM> [--in tables|columns|all]
  • One level below the match, by default. hsql catalog → databases;
    hsql catalog mydb → schemas; hsql catalog mydb.analytics → relations;
    hsql catalog mydb.analytics.orders → columns. Exactly one fetch_children() call.
    --depth N opts into recursion; there is no unbounded default.

    Counting depth from the match rather than from the root is what makes this safe
    rather than merely conservative: at every level, depth 1 is a single round trip, so
    there is no path on which the default degrades into a walk. Depth ≥2 from a schema is
    the first N+1, and it's opt-in.

  • Patterns are paths with an optional trailing glob. A trailing wildcard filters one
    resolved parent's children and stays a single round trip, so analytics.* is just
    sugar for analytics, and analytics.ord* narrows it. An interior wildcard
    (*.orders, mydb.*.orders) can't be evaluated without fetching every candidate
    level, so it belongs to find below — never something a catalog call does quietly.

  • Report child counts wherever the adapter can get them cheaply. 400 relations next
    to a schema is what lets an agent decide not to recurse. Cost should be predictable
    before the call, not just measurable after it.

  • A node budget. --max-nodes (default ~2000) stops a recursive walk and says so,
    naming the path to narrow. Same rule as row truncation: never silent.

  • --stats reports round trips, so the expensive shape of a query is visible.

  • Emit query_name, not just the label. Adapters already compute the correctly
    quoted identifier for every item. Handing it over means the agent never guesses whether
    this backend wants "Orders" or `orders`. It costs nothing and no other client
    does it.

  • --format compact stays for the leaf and shallow-recursion cases:
    analytics.orders(id BIGINT, customer_id BIGINT, total DECIMAL(18,2)). Roughly an
    order of magnitude cheaper than pretty JSON and perfectly readable by a model.

  • describe returns one object in full — columns, types, comments, row counts where
    the adapter can supply them. One round trip.

Amended in M2's plan, on four things this section assumes the catalog contract can
already do.

A listing is one round trip per path segment, plus one — four for
--catalog --path mydb.analytics.orders, not one. An item can only be reached through
its ancestors, so the resolve in front of the listing is itself a walk. The listing stays
single-level and cheap; the resolve is what a network database charges for.

--depth N is one number over levels with wildly different costs, so M2 ships no
recursion at all.
Depth 2 from the root fetches each database's schema names — one
round trip per database, and databases are few. Depth 2 from a schema is one round trip
per relation: measured, 400 relations is 403 round trips and 2.3 seconds against a
local DuckDB file
, on the call an agent would most want to make. Recursion comes back
with fetch_descendants(), which is what would make it a single query rather than a
walk with a budget bolted on.

Child counts have no cheap case. A count is only knowable by fetching the children,
so "report child counts wherever the adapter can get them cheaply" cannot be implemented
as written. What a listing can report is what it fetched, which it already does.

describe is catalog on a relation, so there is no separate verb. One level below
a relation is its columns, with their names, real types and quoted identifiers. All a
second mode could add is detail the contract does not carry (comments) or that costs a
table scan (row counts).

--format compact is cut, and the type it wanted is added. CatalogItem.type_label
is a 1–3 character label for a tree column (##, s, ts), so the type an agent needs
is not in the contract at all — M2 adds an optional type_name, which the in-tree
adapters already fetch and discard, falling back to the short label where an adapter
never populates it. The format goes: parent(child TYPE, …) only reads for a relation
and its columns, and what a level is belongs to the adapter, so a database's schemas
would render as a signature they are not. -tA --format csv over the same rows is as
cheap and is true at every level.

`hsql find` — the real gap

What an agent most often needs isn't "list this schema," it's "where does orders
live"
or "which tables have a customer_id." That cannot be a walk.

This wants an optional adapter capability (implements_catalog_search): one
information_schema-style query where the adapter can serve it, and an explicit "not
supported by this adapter" where it can't, surfaced through the capability flags in
hsql info. High value, but it should not block the rest of the workstream.

An adapter-API question this raises

fetch_children is per-item, which is exactly right for a viewport-driven TUI and forces
N+1 for bulk access. An adapter that could pull an entire schema's columns in a single
information_schema query has no way to say so.

Worth considering an optional fetch_descendants(depth) on InteractiveCatalogItem,
with a default implementation that simply walks fetch_children. Adapters that can do
better override it; everything keeps working for those that don't. This is a
Harlequin-core API decision, not an hsql one, and it would improve the TUI's
expand-all path too.

Stories:

  • B1. As an agent, I run hsql -P prod catalog and then hsql -P prod catalog mydb
    to orient myself, paying one query per step and knowing what each step cost.
  • B2. As an agent, I see analytics — 400 relations and scope my next call instead of
    recursing into a walk that would take a minute and blow my context.
  • B3. As an agent, I run hsql -P prod catalog mydb.analytics.orders --format compact
    and write a correct join on the first attempt, pasting the adapter's own quoted
    identifiers rather than guessing at them.
  • B4. As an agent handed an unfamiliar warehouse, I run hsql find orders and get a
    path instead of walking three levels to look for one.

`hsql spec --json`

Dump the full, installed CLI surface — including options contributed by whichever
adapter plugins are present — as JSON. Harlequin already builds these dynamically from
AbstractOption, so the data exists; it just isn't reachable.

This is unusually valuable here precisely because the option surface is plugin-driven.
No model's pretraining can know that this machine has harlequin-databricks installed
with its particular options. Self-description turns a guessing game into a lookup.

  • B4. As an agent, I run hsql spec --json once at the start of a task and never
    hallucinate a flag for the rest of it.

`hsql info --json`

The debug-info screen, headless: versions, installed adapters, discovered config files in
precedence order, the active profile, and capability flags per adapter
(implements_cancel, implements_copy, implements_read_only,
implements_validate_sql, supports_transactions). Credentials and connection strings
redacted.

Capability flags matter: they let an agent know not to try --dry-run on an adapter
that can't validate, instead of trying and failing.

Amended in M2's plan. The flags have to be declared on the adapter class, not
detected on the connection. Reflection can't answer the question:
HarlequinSqliteConnection defines validate_sql and raises NotImplementedError from
it, so an override check reports SQLite as validating SQL. Nothing is broken by that
today — the app catches the error at the call site — but a class variable is the pattern
that can be read before the call, and the only one hsql --info can read while the
database is unreachable, which is when a diagnostic is worth most. So --info imports
adapters and opens no connection.

  • B5. As an agent debugging a human's setup, I run hsql info --json and can tell
    them exactly which config file is winning and why.
  • B6. As an agent, I check capability flags before offering the human a feature their
    adapter doesn't support.

`hsql fmt`

Expose sqlfmt, which is already a dependency. hsql fmt query.sql,
hsql fmt -c "select 1", --check for CI. Agents write inconsistent SQL; this
normalizes it before it lands in a human's repo. Cheap, and a genuinely nice human
feature too.

Cut in M2's plan. An agent that wants formatted SQL can run sqlfmt, which is what
this would have wrapped. It is also the only headless mode that would have forced a
196ms sqlfmt import into a package whose import graph is otherwise an enforced contract —
paid by the mode alone, but a deferral to maintain forever for a wrapper nobody asked
for.

`hsql history`

Query history is already cached per connection — but as a pickle. Expose it
(hsql history --json -n 20) and additionally write an append-only JSONL log, so both
agents and humans can read it with ordinary tools.

  • B7. As an agent joining a task mid-stream, I read the human's recent query history
    and continue their line of investigation instead of starting over.

7. Workstream C — The config file as a formal contract

Agents write config files. Today they'd be guessing at TOML structure from prose docs,
and a wrong guess produces a confusing runtime error. The config is shared between
harlequin and hsql, so this serves both.

Deliverables

  1. A published JSON Schema for .harlequin.toml, at
    https://harlequin.sh/schemas/config/v1.json and shipped in the package. Enables the
    #:schema comment for taplo / Even Better TOML — editor validation for humans, and a
    verifiable target for agents.
    • Wrinkle: adapter options are dynamic. Solution: publish a base schema for the
      site, and have hsql config schema --json generate a locally-accurate one that
      includes the installed adapters' options.
  2. hsql config validate [--json] — structured diagnostics with file paths and line
    numbers, so an agent can fix its own mistake without a human in the loop. The existing
    _raise_on_bad_schema messages are already good; they just need a machine channel.
  3. hsql config show [--json] — the effective merged config, with provenance per key
    (limit = 500 # from ~/.harlequin.toml). This is the single best troubleshooting
    artifact we could produce.
  4. hsql config init --non-interactive — the TUI wizard (harlequin --config) is
    questionary-only, so agents can't use it. A non-interactive path that edits TOML
    through tomlkit (preserving the human's comments and formatting) means an agent can
    safely modify a config it didn't write. The interactive wizard stays exactly where it
    is; each command gets the affordance right for its audience.
  5. Environment variable interpolation (${DB_PASSWORD}) in config values — closes
    #898, and it's a prerequisite for
    agents and CI ever touching a config that involves secrets.
  6. A formal spec page on the site: discovery order, merge semantics, precedence,
    every key with its type and default, and the reserved/invalid names.

Amended in M2's plan, on two things this workstream would otherwise document as they
stand.

The merge is per top-level key, not per profile, and the result is a start-up
failure.
_merge_config_files() is a dict.update() per file, so a cwd config file
that defines any profile replaces the home file's whole profiles table — while leaving
its default_profile in place, since that is a different top-level key. Both commands
then refuse to start: "Config files set the default_profile to personal, but do not
define a profile with that name."
Reproduced on this checkout, and against the
pre-refactor config.py as well, so it is long-standing rather than something the
tomllib change introduced. M2 makes the merge per-key and treats it as a bug fix; the
spec page should describe that rather than what exists today.

Validation runs on the merged document, so no error can name a file. load_config()
merges every discovered file and then validates the result, which means a diagnostic can
only ever point at a key in a structure nobody wrote. M2 validates each file as it is
read and merges only valid ones, which is what makes deliverable 2's file-and-line
diagnostics possible. It also adds hsql --config list-profiles, since the first
question a human asks is what they can pass to -P, and a missing name is how the merge
bug above becomes visible.

${VAR} interpolation has to be a read-path transform. harlequin --config reads
the config and writes it back, so resolving values too early bakes a plaintext password
into the user's file on their next wizard run. The syntax is ${VAR} and
${VAR:-default} — a bare { never triggers it, so nothing anyone has in a password
today needs escaping — and an unset variable with no default is a config error naming
the variable and its file, never an empty string.

Secrets: #667, #898, and redaction are one feature

#667 asks for a password prompt, and
the maintainer's read at the time was that it wants a new type of adapter option.
That's the right shape, and it does far more than serve the prompt: it turns redaction
from a discipline into a property of the declaration.

The decisive argument is the plugin ecosystem. Core cannot enumerate every adapter's
secret — --service-account-key, --token, --tls-key, whatever the next adapter
invents. But each adapter can declare its own, once, and then every consumer gets it
free: hsql info --json, config show, spec --json, error output, the debug-info
screen, and the config wizard's masked input.

Combine that with ${VAR} interpolation (#898) and the secrets story closes:

  • PasswordOption (or secret=True on AbstractOption) — declares an option as
    sensitive. Drives hide_input at the prompt, a masked widget in the TUI, and
    redaction everywhere else.

  • Never prompt in headless mode. A prompt that blocks on stdin is the worst
    failure mode for an agent: no output, no exit code, the whole turn burned until
    something times out. hsql must fail fast with a message pointing at the profile or the
    env var.

    Amended in M2's plan: no --password-stdin either. Between profiles, ${VAR}
    interpolation and the variables every driver already reads, it fills no gap worth a
    flag — and the gap it does fill, it fills by consuming the stream -f - wants.

  • spec --json marks secret options. This is what teaches an agent not to
    construct hsql --password hunter2, which would leak through ps and shell history
    to every other user on the box. The skill should say it too, but the machine-readable
    flag is what makes it checkable.

  • Scrub known secret values from outbound strings as a backstop. Declarative
    redaction covers values we own; a driver exception that echoes a DSN does not go
    through our option layer.

  • Connection strings need their own handling. conn_str is a positional argument, so
    no option type covers postgres://user:pw@host/db. It needs DSN-aware redaction of its
    own — and #354 is evidence that
    people really do put passwords there.

  • History is the one surface this can't fix. create user x password 'y' is a secret
    inside a query. Worth saying plainly in the docs that history is local-only and may
    contain secrets by construction.

Stories

  • C0. As a human, I hand my agent a profile name, and no secret ever appears in an
    argument list, a transcript, or ps output.
  • C1. As an agent, I add a prod profile to a human's existing config without
    clobbering their comments or reformatting the file.
  • C2. As an agent, I validate the config I just wrote and get a line number when I
    get it wrong.
  • C3. As a human, my editor red-squiggles a typo'd key before I ever run Harlequin.
  • C4. As an agent, I write a config that references ${SNOWFLAKE_PASSWORD} and never
    see the secret itself.

8. Workstream D — Docs that machines can read (harlequin.sh)

The docs are already markdown files on disk (src/docs/**/*.md) and there's already an
/api/docs index route. Most of this is plumbing we half-have.

D1. `llms.txt` and `llms-full.txt`

Highest ROI item on the site. llms.txt at the root: an index of every docs page with a
one-line description and a link. llms-full.txt: the whole documentation set
concatenated as clean markdown. Both prerendered at build time. An agent fetches one URL
and knows everything.

D2. Raw markdown per page

https://harlequin.sh/docs/getting-started/running.md returns text/markdown.
Implemented as a catch-all +server.ts reading the source via import.meta.glob(..., { query: '?raw' }).

The real work is sanitization: the sources carry mdsvex <script> blocks and Svelte
component tags (<Tip>, <Figure>, <Key>). The raw route must strip the script
blocks and downgrade components to plain markdown equivalents (<Tip> → a blockquote,
<Figure> → an image with the caption as alt text, <Key> → backticks). Worth
budgeting real time for; a raw route that emits Svelte tags is worse than none.

D3. Docs API v1

Extend the existing endpoint into something documented and stable:

  • GET /api/docs → index (exists today)
  • GET /api/docs/{topic}/{page} → { title, topic, slug, markdown, updated_at }
  • GET /api/docs/search?q= → keyword search over a prebuilt index (the corpus is small;
    no vector store needed)
  • CORS *, cached with the ISR config already in use.

D4. Copy / view as markdown

A "Copy page as Markdown" button and a "View as Markdown" link in the docs layout. The
copy variant should prepend the canonical URL and title as a header so pasted context
carries its provenance. Small, cheap, and increasingly expected.

D5. A "Headless & Agents" docs topic

A first-class topic in the sidebar, not a footnote. Pages:

  • The hsql CLI — overview and tutorial
  • Output Formats — the table from §5, with examples
  • Exit Codes and Error Handling
  • Catalog and Introspection
  • Safety: read-only, timeouts, dry runs
  • Differences from psql
  • Config File Spec (links to Workstream C)
  • Using Harlequin with Claude Code and other agents — the skill
  • The MCP server

The topic is named for the capability rather than the binary, so a human who doesn't yet
know hsql exists can still find it.

D6. Homepage positioning

The two-command story from §4 needs a home on the landing page — a short section that
says Harlequin is the IDE, hsql is the same engine headless, and both speak fifteen
databases. This is the page a human sends to their agent, which makes it the entry point
for P3.

D7. `AGENTS.md` in the repos

For agents working on Harlequin: repo layout, how adapters plug in, how to run tests
(make check), the snapshot-test workflow, and the changelog convention. Add it to this
repo and to harlequin-adapter-template so new adapters inherit it.

Stories

  • D1s. As an agent helping a human install Harlequin on Windows, I fetch
    llms-full.txt and answer from current docs instead of stale pretraining.
  • D2s. As an agent, I fetch one page as markdown and spend a tenth of the tokens I'd
    spend on the rendered HTML.
  • D3s. As a human, I click "Copy as Markdown" and paste an accurate page into my
    agent's context.

9. Workstream E — Integrations: skill first, MCP last

The recommendation, and why

Ship a skill first. Make MCP a thin wrapper over the same execution core, not a
parallel implementation.

A good CLI plus a skill — one markdown file that teaches hsql — works in any agent
harness that can run a shell command: Claude Code, Codex, Cursor, aider, a bash script,
CI. It costs one file to maintain, adds zero tokens to requests that don't use it, and
keeps a single tested code path.

MCP earns its keep in exactly two places, and they're real:

  1. Harnesses without shell access — Claude Desktop, chat clients, some IDE
    integrations. The CLI is invisible to them.
  2. Connection reuse and session state. For Postgres, BigQuery, Trino, and Databricks,
    connection setup dominates a short query. A persistent server also preserves temp
    tables, session settings, and open transactions across calls, which a fresh process
    per query cannot.

So: both, in that order, sharing one core.

E1. The Harlequin skill

SKILL.md in this repo, published on the site, and installable. Contents:

  • Discover the setup first: hsql info --json, hsql spec --json
  • Use profiles, never raw credentials
  • Orient with catalog --depth 2, then scope with a pattern
  • Run with -c; pick a format on purpose; respect the limit; check for truncation
  • Branch on exit codes
  • Prefer --read-only unless the human asked for a write
  • When to stop and hand off to the human's TUI — this is the part nobody else will
    write, and it's what makes the skill feel like Harlequin's rather than generic

E2. `hsql mcp`

hsql mcp --profile prod starts a stdio MCP server. Deliberately small tool surface —
every tool is tokens in every request:

  • run_query(sql, format?, limit?)
  • list_catalog(pattern?, depth?)
  • describe_object(name)
  • explain_query(sql)
  • format_sql(sql)

Plus MCP resources for the catalog and for the docs (llms-full.txt), which is the
idiomatic way to expose reference material without spending tool-schema tokens.

Read-only by default; writes require an explicit --allow-writes. The MCP dependencies
ship as an optional extra so the base install stays lean.

Sequenced last, deliberately (M6). Everything above it — the execution core, the
catalog navigation, the capability flags, the skill — is what an MCP server would be a
wrapper around. Building it before those settle means designing tool schemas against a
moving target and then maintaining the mistakes. Shipping it last also means we'll know
from real skill usage which five tools are actually worth their tokens, rather than
guessing.

Stories

  • E1s. As an agent in Claude Code, I install the Harlequin skill and immediately use
    the right flags, formats, and limits without trial and error.
  • E2s. As a human in Claude Desktop with no shell, I add the Harlequin MCP server and
    ask questions about my Postgres database.
  • E3s. As an agent running twenty queries against BigQuery, I reuse one connection
    instead of paying setup cost twenty times.

10. Workstream F — Human ↔ agent handoff

This is the part only Harlequin can build, and I'd resist the temptation to sequence it
last just because it's the least conventional.

  • hsql open query.sql launches the TUI with the query loaded in a new buffer,
    not executed. The agent drafts; the human reviews, edits, and runs. Buffers already
    persist through editor_cache.py, so the machinery is close at hand. Having a verb for
    this on the agent-facing command is exactly the ergonomic win the two-command split
    buys us.
  • "Copy CLI command" action in the TUI — writes hsql -P prod -c '<current buffer>' --csv to the clipboard, so a human can hand their agent something exactly
    reproducible.
  • Readable history (Workstream B) closes the loop the other way.

On issue #952 — natural language to SQL

I don't think Harlequin should embed an LLM. No API keys, no model selection, no
provider coupling, no support burden for someone else's rate limits.

But there's a middle path that fits Harlequin's plugin ethos and answers the actual
request: a configurable external-command hook. Let the user define, in their config,
a command that receives the current buffer (and optionally the catalog) on stdin and
whose stdout is inserted into the editor. Then Ctrl+G runs whatever the user
already trusts
— claude -p, llm, a local model, a company-internal endpoint. Same
shape as the existing external-editor request (#767,
#769), and it inherits their
credentials rather than asking for new ones.

Stories

  • F1. As an agent, I draft a migration and hsql open it in the human's TUI for
    review rather than executing it myself.
  • F2. As a human, I explore interactively, then copy a reproducible hsql command for
    my agent to parameterize and run in CI.
  • F3. As a human, I press one key in the TUI and my own AI tool rewrites my query —
    using my own credentials, not Harlequin's.

11. Roadmap

Because the CLI is a separate command, every milestone here is purely additive to
harlequin
and can ship in 2.x minors. Nothing waits for a major version.

Milestone Theme Contents
M0 Secure the name Publish hsql to PyPI as a metapackage depending on harlequin, so pip install hsql works today and the name can't be taken while the rest of this ships.
M1 hsql Second console script; extract the shared execution core; import-linter rule and cold-start benchmark in CI. -c, -f, stdin, -o, --format + shorthands, default --limit 500, truncation notices, --stats, exit codes, --on-error, --color/NO_COLOR, plain errors. Unknown-option hint on harlequin. Docs: the "Headless & Agents" topic, seeded. Closes #524.
M2 Self-description & safety catalog (one level below the match, child counts, node budget), describe, info --json, spec --json, fmt, config validate/show/schema/init, capability flags, secret option type + declarative redaction (#667), env interpolation (#898), --read-only, --timeout, --dry-run. Published JSON Schema. Stretch: find + implements_catalog_search, optional fetch_descendants.
M3 Docs for machines llms.txt, llms-full.txt, raw .md routes, Docs API v1, copy-as-markdown, homepage positioning, AGENTS.md.
M4 Skill & handoff Skill, hsql open, JSONL history, "Copy CLI command", external-command hook.
M5 Scale Streaming output, --offset/pagination, memory work for large results (#875).
M6 MCP hsql mcp over the M1 execution core: run_query, list_catalog, describe_object, explain_query, format_sql, plus catalog and docs resources. Read-only by default.

On M1. Implementation is planned in detail in
the M1 technical plan: the module architecture, the
refactors that have to land first, and an eight-PR sequence across two releases. A few
figures and defaults in this document were corrected against measurements taken while
writing it; §8 there lists them. PRs 1–6 shipped and PR 7, the docs topic, is in flight;
PR 8, the agent eval suite, was not built.

On M2. Likewise in the M2 technical plan, across three
releases rather than one: config and self-description first, since they fix bugs that
exist today and need no adapter change at all; then the catalog, with search implemented for
both in-tree adapters rather than left as a stretch; then the safety flags, which need
additive members on the adapter contract and therefore an ecosystem rollout. The amendments
marked above are the corrections that came out of measuring it. This table's M2 row is
otherwise all subtraction: the introspection surface is mode options rather than
subcommands, and describe, fmt, --dry-run, --single-transaction, --password-stdin,
recursive --depth, the node budget, child counts and the compact format are all cut —
each for a reason given in §7 of that plan.

On M0. Name availability is the only thing in this plan that someone else can take
while we deliberate, which is why it's a milestone rather than a task. It's also cheap
enough to do this week.

A few things worth getting right the first time:

  • Metapackage, not a second copy of the artifact. hsql should be a small
    distribution whose only dependency is harlequin. Republishing the same built wheel
    under a second name would put duplicate modules on disk and conflict outright if a user
    installed both.
  • Version it independently and float the dependency — hsql 0.1.0 requiring
    harlequin>=2.x — so it doesn't need a release every time Harlequin cuts one. (Also
    reversed once M1 shipped: it now takes Harlequin's version and pins it. See the status
    note below.)
  • No placeholder console script. It's tempting to ship an hsql command that prints
    "coming soon," but from M1 the harlequin distribution itself provides that entry
    point, and two installed distributions claiming the same script name is a mess to
    unwind. pip install hsql giving you Harlequin, plus a README that says what's coming,
    is honest enough. (M1 shipped, and the metapackage does declare the script now — see
    the status note below. The half of this that held up is "no placeholder": it points
    at Harlequin's entry point rather than standing in for it.)
  • Reserve the same name anywhere else Harlequin is published or packaged while we're at
    it.

This is a genuine, working package rather than a squat, which also keeps it on the right
side of PyPI's naming policy.

Status: the metapackage lives in packaging/hsql/, and M1 changed two of the decisions
above.

It declares hsql = "harlequin.hsql:main" — the same entry point Harlequin's own script
uses, so the two console scripts are byte-identical and co-installing them is a no-op.
Without it, uvx hsql still ran, but uv warned that the executable came from a
dependency and suggested uvx --from harlequin hsql, on the one command line this
package exists to make short.

And it ships with Harlequin rather than on its own cadence: publish.yml builds and
uploads both from one release, and the metapackage carries Harlequin's version number and
pins that release exactly — release.yml sets all three numbers with uv version and
uv add, and test_packaging.py fails the release PR if they ever disagree. So hsql 2.9.0 is harlequin 2.9.0, whichever name a caller installs by. The independent cadence
was the right call while this package was a reserved name and nothing else; once it
points at an entry point that only some releases have, a floating floor is a promise the
metapackage cannot keep.

--read-only sits in M2 rather than M1 only because it requires an adapter-interface
addition and therefore an ecosystem rollout; if that lands early, pull it forward. It's
the flag that makes everything else socially acceptable.

If only three things get built

  1. hsql with -c / -f / -o / --format, a strict stdout/stderr split,
    documented exit codes, and honest truncation.
  2. hsql catalog --format compact.
  3. llms.txt + raw markdown docs + the "Headless & Agents" topic + the skill.

That trio takes an agent from "reinvents psql badly" to "writes a correct query against
any of fifteen databases on the first try."


12. Risks and open questions

  • Two front doors can drift. The whole bet rests on one shared execution core. If
    hsql grows its own query logic, or the TUI keeps a parallel path, we've built two
    products. Mitigation: extract the core in M1 and refactor the TUI onto it in the same
    milestone, not later.
  • Discoverability of hsql. Nobody guesses it exists; harlequin -c is guessable.
    Mitigated by the unknown-option hint, the docs topic named for the capability rather
    than the binary, homepage positioning, and the skill. Worth watching — if support
    questions suggest people can't find it, revisit.
  • Name mindshare. hsql competes with HyperSQL in search results. No PATH or PyPI
    collision, but SEO for "hsql" will take work; docs should always spell it "hsql, the
    Harlequin CLI" on first use.
  • Adapter ecosystem lift. --read-only, validate_sql, catalog search, and any bulk
    fetch_descendants are optional adapter methods. hsql must degrade gracefully and
    say which adapter lacks what — hence capability flags. Third-party adapters will lag;
    plan for a long tail.
  • Catalog fetch cost is the sharpest edge in the plan. The default is safe by
    construction (§6), but --depth 2 on a wide schema is an N+1 walk on most adapters,
    and it's also the single most useful catalog call an agent can make. Child counts, the
    node budget, and --stats are what keep that honest until adapters can serve it in
    one query.
  • Cold start. Measured rather than feared, and worse than this section assumed: a
    bare harlequin --version takes 1.3s today. Lazy entry-point resolution is a
    requirement of M1, not a contingency — load_adapter_plugins() imports every installed
    adapter on every invocation, worth 160ms with four installed and unbounded as the
    ecosystem grows. An import-linter rule in this repo would also never have caught the
    largest single cause, which is upstream: harlequin.adapter pays 265ms for
    textual-fastdatatable, whose backend needs no Textual but whose package __init__
    imports the DataTable widget. See the M1 technical plan.
  • Memory. Materializing an Arrow table for a large export is the same failure as
    #875, and it is not fixed in M1. See the streaming note in §5.
  • Secrets. info --json, config show, error messages, and history are all leak
    surfaces, and agents paste output into transcripts. Mitigated structurally by the
    secret option type in §7 (#667) rather than by remembering to redact at each site —
    but note that connection strings and query history sit outside that mechanism and need
    their own handling.
  • Two help surfaces to keep coherent. harlequin --help and hsql --help share the
    adapter option matrix and must not disagree. Generate both from the same option
    definitions.
  • Docs drift. spec --json output and the site's config spec must be generated from
    the same source, or they'll disagree within two releases.
  • MCP maintenance. Only worth it as a thin wrapper. If it starts growing its own
    query logic, that's the signal we got the layering wrong.

13. How we'd know it worked

  • hsql -c "select 1" against DuckDB completes in < 300ms, tracked in CI.
  • The hsql module graph contains no textual import, enforced in CI.
  • An agent eval suite in CI: a handful of realistic tasks ("find the top 10 customers
    by revenue") run against fixture databases with a small model, scored on wrong-flag
    retries and task completion. This is the metric that actually matters, and it's the one
    that would keep the CLI honest as it grows. I'd build it in M1 with three tasks and
    expand it.
  • An agent given only hsql --help completes a query task with zero wrong-flag
    retries.
  • llms.txt is served, and every docs page is fetchable as clean markdown.
  • #524, #667, and #898 closed; #952 answered without embedding an LLM.
  • Qualitatively: someone writes "just use hsql, it works the same everywhere" in a thread
    about getting an agent to talk to a database.

Plans Hsql Warm Sessions Design (`docs/plans/hsql-warm-sessions-design.md`)

Warm sessions for `hsql` — a design for `hsql --serve`

A design for a long-lived hsql process that holds an open connection, so that repeated
invocations pay neither the import cost nor the connection cost again.

Revised against 2.13.0, with M1,
M2 and M3 shipped and SSH
tunnels landed; the first draft was written against 2.9.0, when M2 was still a plan. Every
number below is re-measured on this checkout — Python 3.10.15, Linux, four adapters
installed — on a machine about a third slower than the one the first draft used, so read
the ratios rather than the absolutes. §10 lists what the revision changed, for a reader
who has the first draft in their head. PR 1 has shipped; §9 says what is in it.

Bottom line up front. The idea works, and the win is bigger than the quarter-second it
was proposed to recover — but only if one constraint is treated as the load-bearing one:
the client must not import anything Harlequin owns. The client built to that rule and
shipped in PR 1 answers select 1 in 27ms against 348ms cold, and 20 sequential
invocations in 0.55s against 7.5s. A client that imported click and nothing else
would already be near 65ms, and one that imported harlequin.config near 90ms (§2) — so
the difference between a good version of this feature and a mediocre one is decided by the
first few lines of the console script, not by the protocol.

The other finding is that speed is the smaller half. A warm process is a session:
temp tables, SET, search_path, and open transactions survive between invocations. That
is a genuine feature for an agent — and it is also the reason this cannot be turned on by
default, cannot be auto-started in v1, and cannot be a silent fallback. §5 is the part of
this document I would argue about; §1–§2 are mostly arithmetic.

Recommended shape, in one line:

bash
hsql --serve prod ./warehouse.db      # foreground, holds the connection
HSQL_SESSION=prod hsql -c "select 1"  # 27ms, same bytes, same exit code

1. Where the 350ms actually goes

hsql -c "select 1" against in-memory DuckDB, best of twenty runs of the real console
script:

Invocation Best Median
hsql -c "select 1" 348ms 362ms
hsql -c "select 1" -a sqlite 323ms 336ms
hsql --help 130ms 134ms
harlequin --version 895ms 917ms

Instrumenting the same work phase by phase, cumulative from process spawn:

Phase Cumulative Cost
interpreter ready 17ms 17ms
import click 53ms 36ms
harlequin.config 87ms 34ms
harlequin.plugins 93ms 6ms
load_adapter("duckdb") 163ms 70ms
harlequin.query (pyarrow + textual_fastdatatable.backend) 266ms 103ms
adapter() + connect() 279ms 13ms
the rest: harlequin.statements, harlequin.layout, and the query 329ms 50ms

That last row is mostly two more imports — tree-sitter for the splitter and wcwidth for
the layout. Measured in a process that has already paid for all of them, connect() is
10.8ms and execute() + fetch() + layout together are 5.0ms.

So the database work is 5ms of a 350ms invocation. Everything else is getting ready to
do it. M1 already took the cheap wins — lazy entry points, the lazy
textual-fastdatatable __init__, deferred harlequin.query — and what is left is
genuinely irreducible on the cold path:

  • pyarrow, 103ms with the backend. harlequin.query normalizes every result set
    through create_backend(), which is the whole reason both front ends agree about what a
    row is. Removing it means a second normalizer, which the M1 plan rejected for good
    reasons that have not changed.
  • The driver, 70ms for duckdb. Not ours.
  • click, 36ms. Ours to remove in principle, at the cost of hand-rolling the option
    surface that adapters plug into. Not worth it.
  • CPython itself, 17ms. The floor for any Python process on this machine.

So: even a version of hsql that imported nothing of Harlequin's beyond click and its
config would land near 200ms, and one that also dropped click near 165ms. There is no
sequence of import fixes that reaches 50ms.
That is the honest case for a second
process, and it is worth stating in the plan because the alternative — "keep optimizing" —
has been the right answer up to now and stops being the right answer here.

1.1 And `connect()` is 11ms only because DuckDB is a library

The table above understates the win, because the adapter it measures opens a file. The
in-tree number is connect() at 10.8ms for in-memory DuckDB, and a local file or SQLite
is the same order. Every adapter that speaks to a server — Postgres, MySQL, BigQuery, Trino,
Databricks, Snowflake — pays TCP setup, a TLS handshake, and an auth round trip on each
connect(), and for the OAuth-shaped ones a token exchange on top. Those are the
connections a human hands an agent, and they are the ones where holding the connection open
matters more than holding the imports.

This design should therefore not be sold on "350ms → 27ms against DuckDB." It should be
sold on the invocation cost stops depending on how far away the database is, with the
DuckDB number as the floor case rather than the typical one.

2. What a warm session costs instead — measured, not projected

The first draft measured a throwaway prototype. These are the shipped client from PR 1
— the real main(), the real harlequin.hsql.client, the real frames — against a stub
server that speaks protocol.py and returns fixed bytes without touching a database, so
the number is the invocation's cost rather than a query's. Same machine, twenty runs:

Best Median
warm round trip, select 1 26.6ms 27.6ms
cold hsql -c "select 1" 348.2ms 361.8ms
20 sequential invocations, warm 547ms —
20 sequential invocations, cold 7520ms —

The warm client's 27ms decomposes almost entirely into things that are not the feature:

Cumulative Modules
CPython boot (python -c pass) 16.8ms 33
import harlequin.hsql (the entry point) 17.9ms 36
import harlequin.hsql.session (the decision) 18.0ms 37
import harlequin.hsql.client (socket, struct, protocol) 26.1ms 58
connect, send, receive, write, exit ~1ms —

The round trip is about a millisecond. The client is ~95% interpreter start-up, which
means the protocol can afford to be careful — framing, a handshake, a version check, a
capability exchange — without moving the number. It also means the ceiling is 17ms unless
the client stops being Python, which §7 leaves as future work rather than pretending it is
free.

25 modules over a bare interpreter is the whole of it, and that is why §6's guard
counts modules rather than milliseconds: a count is the same on every machine, where a
wall clock on a shared CI runner is not. For scale, on the same interpreter import click
is 100 modules, harlequin.config 141 and harlequin.hsql.diagnostics 149.

2.1 The constraint this puts on the entry point

import click costs 36ms and import json costs 9ms. Against a 27ms invocation those are
133% and 33%. So the rule is not "the client should be lean," it is:

harlequin.hsql.main() must decide whether this invocation belongs to a session, and
hand off to a stdlib-only module, before it imports anything that is not stdlib.

That is the single most important line-level decision in this document, and the first
draft under-scoped it. Three things were on the entry point's path, not one, and PR 1 took
all three off:

  • click, 36ms. main() opened with import click. It now looks at HSQL_SESSION and
    --session first — a sys.argv scan and an os.environ lookup, both free — and only
    falls through to build_cli() when there is no session.
  • harlequin.hsql.diagnostics, 68ms. The bigger one, and invisible until measured:
    harlequin/hsql/__init__.py imported ExitCode from it at module scope, and that
    module reaches harlequin.redact → harlequin.config → msgspec. Deferred into the cold
    path, where it was already being imported anyway.
  • typing, 10ms. harlequin/__init__.py imported it, and importing any
    harlequin.* submodule executes that file. Every annotation there is a string under PEP
    563, so the module now declares TYPE_CHECKING = False itself and imports nothing at
    all. The four modules on the client's path do the same.

A consequence worth stating, because it looks like a violation of a rule this repo has:
the client writes its own stderr rather than going through diagnostics._write(),
which is otherwise "the one line every message leaves through". Nothing is lost. That
function exists to redact, and the client holds no secret to redact — it reads no profile,
loads no adapter, and never sees a connection string. The server's stderr arrives already
redacted and is copied through untouched.

It also gives the guard a shape CI can hold, exactly as the "no Textual in hsql" rule
does: an import-linter contract saying the client's three modules reach nothing of
Harlequin's and nothing third-party
, plus a run-time test that counts modules against a
bare interpreter. One caveat the first draft missed: import-linter cannot express "this
package's __init__ may not import click at module scope" — a contract's source module
matches its descendants — so that half is a run-time test only
(test_deciding_whether_an_invocation_is_warm_does_not_import_click). Without both, the
first contributor who needs a config value in the client re-adds 68ms and nobody
notices.

3. Why a daemon, rather than the four cheaper things

3.1 Not "keep optimizing imports"

§1 measured the floor at ~165–200ms. Rejected on arithmetic.

3.2 Not a batch mode (`hsql --repl`, statements on stdin)

One process, many statements, no daemon, no socket, no security model. It is strictly
simpler and it is the right tool for a migration script.

It does not serve the caller this feature is for. An agent working through a Bash tool gets
one command per turn and cannot hold a pipe open across turns; a shell script that
wants to interleave hsql with jq and git cannot either. Batch mode solves "many
statements known up front." The daemon solves "many invocations, each decided after seeing
the last one's output" — which is what an agent loop is.

Worth noting they compose: hsql --serve is a batch mode whose batch arrives over a
socket.

3.3 Not MCP instead — but this is the closest call

M6 in the product plan is hsql mcp: a long-lived process an agent talks to over stdio,
running on the same M1 execution core — still the last milestone, with M4 (handoff, in
three releases) and M5 (streaming) ahead of it. It is already a warm session. It holds a
connection, it pays the imports once, and it needs no socket, no file permissions, no
daemon lifecycle, and no uid check, because the agent harness owns the process and the pipe
is the security boundary. If the only persona were "an agent whose harness speaks MCP,"
this document should be closed and M6 pulled forward.

Two reasons it is not:

  1. Most agent harnesses give you Bash, not MCP — and even the ones that speak MCP run
    in contexts (CI, a container's entrypoint, a Makefile, a postCreateCommand) where the
    available interface is a command line. The product plan's own P1 persona is defined as
    "usually inside a coding agent's Bash tool."
  2. The audience is not only agents. A human running twenty hsql calls in a shell loop
    against Snowflake gets the same win, and MCP does nothing for them.

The right relationship is that hsql mcp should be a client of this server, not a
parallel implementation of it.
Both are "a warm connection plus a request loop"; the only
difference is the transport and the serialization. If M6 grows its own connection lifecycle
the plan has two of them, which is the same mistake the plan already refuses to make with
the TUI and the CLI. That is an argument for building the session layer first and letting
MCP land on top — and it is the strongest argument in this document for doing this at all.

3.4 Not a connection pool

A pool would let several clients run at once. It also destroys the thing that makes a
session useful — a temp table created on connection 3 is invisible to the next request that
lands on connection 1 — and it turns "which connection am I on" into a question the caller
has to think about. One session is one connection. §4.5 handles concurrency by serializing
rather than by multiplying connections, and §7 leaves read-only pools as future work.

4. The design

4.1 One command, two roles

hsql --serve NAME [CONN_STR] [connection options] runs the server in the foreground.
Not a start/stop/status subcommand family, and not a self-daemonizing fork:

  • Foreground is what every supervisor already knows how to run — &, systemd, a
    container entrypoint, docker compose, a SessionStart hook, tmux. Self-daemonizing
    processes reimplement all of that badly, and orphaned ones holding a live warehouse
    credential is exactly the failure this feature must not have.
  • It keeps the mode-option shape M2 already settled on (--catalog, --info, --spec,
    --config MODE) rather than introducing the subcommand ambiguity that shape exists to
    avoid: hsql takes CONN_STR positionally, so hsql server foo.db would be ambiguous
    in the same way hsql catalog was.
  • --serve takes the session name as its value, so one flag carries both "be a server"
    and "be this server."

Why `--serve` and `--session`, and not two nouns

The first draft of this document paired --server NAME with --session NAME, and that is
the wrong pair: in database vocabulary "server" and "session" are near-synonyms, and
neither word tells a reader which of the two processes it belongs to. The client half is
effectively pinned — HSQL_SESSION is the ambient spelling and nobody wants to write
HSQL_SERVER for a thing that is not one — so the question is only what the server's flag
is called, and the answer should be a verb:

--serve is a mode option; --session is not. M2's grammar is that mode options say
what an invocation is (--catalog, --info, --spec, --config MODE) and plain
options modify a normal invocation. hsql --session prod -c "select 1" is an ordinary
query invocation — it just runs somewhere warm — so --session is a plain option, and
--serve joins the modes. The grammar already encodes the asymmetry the names need.

Considered and not taken:

Spelling Why not
hsqld NAME CONN_STR + hsql --session NAME The clearest of all, on forty years of sshd/dockerd, and it frees the daemon's own knobs from any prefix. Costs a third console script to package and document, and worse discoverability than hsql — which the product plan already lists as a risk. The runner-up; take it if the daemon's flag surface ever outgrows one command.
--listen NAME Accurate about the mechanism, unambiguous about direction, but reads network-y: someone will look for the port. Wrong prior for a design that refuses TCP on purpose (§4.7).
--daemon NAME Unambiguous and false. This process runs in the foreground; a --daemon flag that does not daemonize is a lie the user finds out about when they try to background it.
--host-session NAME "Host a session" reads well, but -h/--host is a connection option on the postgres, mysql and trino adapters — and hsql's own flags win collisions (first_pass.attach_adapter_options, under the spellings hsql reserves), so this would quietly take --host away from them. Every new flag in this design needs that check. Done, against duckdb, sqlite, postgres and mysql: none of --serve, --session, --idle-timeout, --max-lifetime or --queue-timeout collides with anything any of them declares.
--attach NAME tmux/docker vocabulary, and every agent knows it — but ATTACH is a DuckDB statement for attaching a database. Collides with the subject matter, in a tool whose subject matter is databases.
--connect NAME Overloaded with the connection every other part of this CLI is about.
No server flag: infer the role from argument shape (a CONN_STR and no SQL means serve) This is principle 7's "never make the agent guess," inverted.

One consequence of the rename, applied through the rest of this document: the daemon's own
knobs lose the --server- prefix they only carried to disambiguate — --idle-timeout,
--max-lifetime, --queue-timeout — since they appear only next to --serve. And the two
that are client operations are named for what the caller is addressing:
--session-status and --session-reset, both of which are things you ask a running
session, not things you configure a server with.

What `--serve` accepts, and what it refuses

No, --serve does not accept -c — and the reason generalizes into the rule that
defines the two roles. Every flag hsql has falls into exactly one of three groups, decided
by when the question it answers can possibly be answered:

Group Answered Flags --serve client
connection-time once, when the server connects CONN_STR, -a, -P, --config-path, -r/--read-only, every adapter option, and the five SSH options (--ssh-host, --ssh-forward, --ssh-batch-mode, --ssh-allow-reuse, --ssh-timeout) accepted accepted, but rejected if it differs from the server's (§4.4)
per-request on every invocation -c, -f, -o, --format and the shorthand flags, -t, -A, -x, --no-header, --no-footer, --null-string, --limit, --display-rows, --result, --on-error, --stats, --color, --timeout, and every mode: --catalog, --catalog-search, --path, --config, --info, --spec, --skill refused, exit 2 accepted
server-lifetime once, and only a server has one --idle-timeout, --max-lifetime, --queue-timeout accepted refused, exit 2

The partition is exact and it is symmetric: each side refuses the other's group. That
turns §4.4's differing-connection-options rejection from a one-off into a special case of a
single rule, and it gives both refusals an error that names the right invocation rather than
just the wrong one — principle 7, applied to the feature's most likely first mistake:

text
hsql: -c is a per-request option, and --serve takes none.
      Start the session, then send it a query:
        hsql --serve prod ./warehouse.db
        hsql --session prod -c "select 1"

The one real argument for -c on --serve is an init script — SET search_path,
CREATE TEMP TABLE, INSTALL/LOAD — run once at connect time, which is a genuinely
useful thing for a session to do. It is already served, by the better mechanism:
--init-path (-i) is an adapter option on both in-tree adapters, so it is
connection-time, group 1, and comes along for free. It is also the more correct of the two,
because it runs inside connect(), where the adapter decides what "init" means for its
database and where a failure is a connection failure rather than a request failure. A
-c on --serve would be a second, worse spelling of something that works today.

Adapter options are allowed on `--serve`, and should still be discouraged

They have to be accepted: the server is the only process that connects, so refusing them
would mean the only way to serve anything but a local file is a profile.

But --serve changes their risk profile, and the docs should say so. A server's command
line is long-lived and visible in ps.
A password on a one-shot invocation is exposed for
350ms; the same password on an eight-hour session is exposed for eight hours, to every
process on the box. So:

  • -P is the spelling the docs should show. A profile is already "how to connect to my
    warehouse," and hsql --serve prod -P prod should be the normal form with the flag-soup
    version as the exception.
  • Warn when a secret-typed option is passed literally to --serve, detected through
    the declarative secret= that shipped with #667 rather than a hand-written list of flag
    names. A warning rather than a refusal in v1, because refusing needs env interpolation
    (#898) to have shipped first so that there is a documented alternative for the container
    case. Tightening it to a refusal once #898 lands is a reasonable later call, and a cheap
    one.

The SSH options are connection-time, and a session is where they pay off

They landed after the first draft, and they fit the partition without an exception: a
tunnel is opened once, on the way to connect(), so --ssh-host and its four companions
are group 1. This is the case a warm session is most worth having — a tunnel costs an
ssh handshake on every cold invocation, and holding it open is the larger half of the win
§1.1 describes.

Two consequences the server has to carry rather than invent:

  • The tunnel is part of what the server holds open, so §4.7's "the server is a
    credential" reads on it too, and --max-lifetime bounds it.
  • --ssh-allow-reuse is already a CLI_ONLY_SSH_KEYS key — a config file may not turn
    off the check that the local port is not already someone else's listener, because config
    files are discovered in the working directory. That reasoning does not weaken on a
    server; it is the same flag, read the same way.

Clients opt in two ways, and the distinction matters:

Spelling Missing server →
ambient HSQL_SESSION=prod in the environment warn on stderr, run cold
explicit hsql --session prod -c "..." fail, exit 3 (connection)

A name that could never name a session — one with a / or a space in it, which §4.4's
validation refuses — is the same shape of answer one step earlier: exit 2, because a
bad name is a bad argument rather than a server that is down. Native Windows is the same
again: explicit exits 2 (it can never work here), ambient warns and runs cold.

An environment variable is a preference — the human or the harness set it once, and an
hsql call that still works when the server is down is the behavior that does not break a
script. A typed --session is an assertion, and silently running cold against an assertion
would mean silently losing the temp table the caller was counting on. Same reasoning as
merge_profile_with_cli(): a value the caller actually typed carries intent that a
default does not.

The fallback warning is not optional and not suppressible. "It got slow and
my temp tables vanished" must never be something the caller has to guess at.

4.2 The client is stdlib-only, and forwards argv verbatim

text
harlequin/hsql/
  __init__.py     main(): session check, then click or the client. Stdlib only.
  session.py      which session an invocation names, and where its socket is.
                  Stdlib only, and read before anything else.
  client.py       stdlib only: socket, struct, sys, os. No click, no json, no
                  harlequin beyond session.py and protocol.py.
  protocol.py     framing + the frame vocabulary, shared -- but written so that
                  client.py's half needs no imports the client cannot afford.
  server.py       accept loop, one worker, lifecycle, the uid check.
  cli.py          unchanged, plus --serve / --session

The first three of those shipped in PR 1. session.py is separate from client.py
because the decision has to be cheaper than the client: a cold invocation reads it, finds
no session, and pays two modules and no socket for the question.

The server parses the flags; the client does not. The client scans sys.argv for the
three things it cannot avoid knowing about —

  • --session NAME (strip it; it is the client's own flag),
  • -f - / --file - (read stdin and send the bytes, because the server has no stdin),
  • --session-status (send it as its own frame; §4.10 is answered by the session rather
    than by the command, and it must be answerable while a request is running),
  • nothing else —

and forwards the rest opaquely. Every other flag, including --help, including a
misspelling, is parsed by the server, by the same build_cli() the cold path uses, and
produces the same diagnostic. There is no second parser to keep in step, which is the
failure mode this design would otherwise be most likely to have. hsql --session prod --badflag should print the same message and exit the same 2 as hsql --badflag, and it
will, because it is the same code.

The request carries what the server cannot know: argv, the client's cwd, stdin bytes if
asked for, NO_COLOR, and whether the caller's stdout and stderr are terminals

(the two halves of what --color auto reads: it checks sys.stdout.isatty() and
NO_COLOR, and on the server every stream is a buffer, so auto would otherwise mean
"never" however the caller's terminal looks). TERM is not carried — nothing in the
output path reads it. Deliberately not the
client's whole environment — the server has its own, an adapter option's envvar resolves
against the server's, and shipping the caller's environment across a socket is a credential
leak with no upside.

The cwd is doing more work there than it looks. Config discovery is cwd-dependent — home,
then the user config dir, then the cwd, later winning — so the server must resolve a
client's -P and --config-path against the client's directory, not its own. Otherwise
hsql --session prod -P local run in a project would silently read the server's
pyproject.toml, and a caller would get a profile they did not write. This costs nothing —
the cwd is already in the request for -o (§4.3) — but it has to be deliberate, because the
natural implementation resolves against the process that is running. A profile is then
subject to the same partition as typed flags: its connection-time keys go through §4.4's
comparison, its per-request keys apply normally, and -P needs no special case on either
side.

4.3 What crosses the wire is bytes, not rows

The response is three things: stdout bytes, stderr bytes, exit code. The server runs
harlequin.query and hsql.output exactly as the cold path does, writing into a buffer
instead of click.open_file("-", mode="wb"), and the client copies that buffer to its own
stdout and exits with the code.

This is the decision that makes the feature safe. It means:

  • The client cannot format anything, so it needs no pyarrow, no layouts, no --format
    knowledge, and no ability to disagree with the cold path about a timestamp.
  • Byte-equivalence is testable (§6). Every golden-format snapshot can be asserted
    against both paths.
  • --stats, truncation notices, and the row-cap notice need no protocol support —
    they are already stderr bytes.

Two things need care:

  • Streaming. The buffer means a 500-row default costs nothing, but --limit -1 on a
    large table materializes twice: once as Arrow on the server, once as bytes. That is the
    same failure as #875 and it is not made worse here, but the frame format should be
    chunked from the start (many stdout frames, then a terminating exit frame) so
    that M5's streaming work has somewhere to land. Chunked framing is free now and
    impossible to retrofit without a protocol version bump.
  • -o PATH. The server writes it, resolving a relative path against the client's
    cwd from the request. Not the client: --format parquet writes through duckdb, not
    through our byte stream, and routing it back through the socket would both double the
    memory and break output.py's "one binary stream, -o included" invariant. This is
    also the design's clearest statement that client and server share a filesystem —
    which a AF_UNIX socket already implies.

4.4 Named sessions, not hashed connection identity

The tempting design derives the socket path from a hash of the connection identity, so a
client automatically finds the right server. Rejected:

  • The hash would have to include connection-affecting options and exclude presentation
    ones (--format, --limit, --color), and that partition is not written down anywhere
    today — it would become a new thing to keep in step with every adapter's options.
  • Profiles resolve differently depending on cwd, so the same command in two directories
    would hash differently, or the same hash would mean two databases.
  • Hashing secrets to name a file is a bad habit even when the hash is sound.

Instead: the name is the caller's, and the server owns the identity. hsql --serve prod -P prod connects however -P prod says to, and records what it resolved to. A client
that sends connection-affecting options gets one of two answers:

  • identical to the server's → served;
  • different → rejected, exit 2, naming the flag that differs and what the server has.

Not "reconnect with the new options" (that is a different session, and silently swapping
the database under a caller is the worst possible behavior), and not "ignore them"
(a client that typed -a postgres and got DuckDB has been lied to).

Socket path: $XDG_RUNTIME_DIR/hsql/<name>.sock, falling back to $TMPDIR/hsql-/
(or /tmp/hsql-/) when XDG_RUNTIME_DIR is unset — which is macOS, and also WSL2
without systemd, and also this container (§4.8). The fallback is the common path, not the
exotic one, and the directory is created 0700 and owned by the user either way. The name is
validated as [A-Za-z0-9_-]{1,64} so it cannot escape the directory.

Not platformdirs, which is what the rest of Harlequin uses for a user directory and
what the first draft named here. It costs ~22ms to import — about the whole of what a warm
invocation is supposed to cost — so the derivation is a dozen lines of os.path in
session.py, shared by both halves so they cannot disagree about where the socket is. It
is also the smaller question than it looks: XDG_RUNTIME_DIR is the only path platformdirs
would resolve differently on Linux, and the fallback branch is the one this design has to
get right anyway.

A name that fits is not always a path that fits. sun_path holds 104 bytes on
macOS (108 on Linux), the directory counts toward it, and the TMPDIR macOS supplies is
itself ~50 bytes — so a name well inside [A-Za-z0-9_-]{1,64} can still be one no socket
can be bound to. The client measures the assembled path and refuses with a message that
names the limit, rather than letting connect() fail with a bare errno.

No --session-socket PATH in v1. Letting a caller name the socket file directly is the
obvious escape hatch and the obvious way to end up with a socket on a mode-0777 shared
directory, or on a filesystem that cannot host one (§4.8). If it ever ships it has to
re-derive §4.7's guarantees for a path it did not choose — verify the directory's owner and
mode, and refuse anything that is not a real local filesystem — which is most of the reason
it is not in v1.

4.5 One request at a time

Measured, on this checkout: two threads calling execute() on one DuckDB connection
fail
—

text
HarlequinQueryError('Invalid Input Error: Attempting to execute an
unsuccessful or closed pending query result')

— and this is not a DuckDB quirk. The adapter contract says nothing about thread-safety,
and the TUI has never needed it: its database work runs in @work(thread=True, exclusive=True) workers, which is to say one at a time, and cancel the previous one.
hsql --serve inherits that invariant rather than inventing a new one.

So: a single worker thread, and a request queue. A second client waits. Waiting is
bounded by --queue-timeout (default: the same value as the request's own --timeout,
which shipped in M2), and a client that times out in the queue exits 4 with a message that
says it never reached the database — which is a different fact from a query that ran too long, and the
caller needs to be able to tell them apart.

The accept loop stays responsive while a query runs, so --session-status and cancellation
(§4.6) are answerable mid-query. That is the whole reason the queue is in the server rather
than implicit in a single-threaded accept loop.

4.6 Cancellation, and a real gotcha in the contract

Ctrl-C on a client must stop the query, not orphan it. The client catches SIGINT, opens
a second connection to the socket (the first is busy carrying the response), sends
cancel with its request id, and exits 130.

The server calls connection.cancel() when the adapter implements it. When it does not —
cancel() is an optional method, and HarlequinAdapter.IMPLEMENTS_CANCEL says whether it is real — the honest
behavior is to detach the client, exit 130, and say on stderr that the query is still
running on the server
, because it is.

The gotcha, found while measuring: DuckDB's cursor reports an interrupt by returning None
from fetchall(), not by raising:

python
def fetchall(self) -> AutoBackendType | None:
    try:
        result = self.relation.to_arrow_table()
    except duckdb.InterruptException:
        return None

In the TUI that is fine, because the user pressed the button and the app knows. In server
mode a cancelled query and an empty result are the same bytes
, so the server must
attribute cancellation from its own bookkeeping — "I called cancel() on request 7, and
request 7 came back empty" — rather than from anything the adapter says. Worth writing down
because the alternative is a caller who sees (0 rows) and exit 0 for a query that was
killed.

This is not a new mechanism, and PR 4 should not invent one. --timeout shipped in M2
and has exactly this problem: harlequin/hsql/timeout.py cancels, then reads its own
deadline between results and attributes the empty result itself, and
diagnostics.report_timeout() is the line that says so. The server's bookkeeping is the
same shape, one level up — "I called cancel() on request 7" instead of "the deadline
passed" — and it should read like it.

(A cleaner long-term fix is a distinguished return or exception on the contract, which is
an adapter-ecosystem change of the same shape as M2's five additive members. Out of scope
here; noted in §7.)

4.7 Security: the server is a credential, held open

This is the part that deserves the most conservatism, because a running server is a live,
authenticated database connection that anything able to write its socket can use.
There
is no password on the wire and there does not need to be — the socket is the credential.

  • AF_UNIX only. No TCP, not even on loopback, not behind a token. Loopback TCP is
    reachable by every process and every container sharing the namespace, and a token file
    is a second secret to leak.
  • The socket lives in a 0700 directory owned by the user, and the socket itself is
    created 0600 (bind, then chmod, or umask around the bind — file permissions on a UDS
    are honored on Linux and macOS but not on every kernel, which is why the directory mode
    is the real control).
  • The server refuses a request whose argv names -f - and carries no stdin
    section.
    The client's argv scan is a scan, not a parse, and its set of
    boolean short flags is hsql's own — a boolean short an adapter declares is
    not in it, so -zf - for such a -z would forward no stdin. One check on the
    server turns every miss of that shape, including ones nobody has thought of,
    into an exit 2 rather than a silently empty script. The server never reads its
    own stdin.
  • Both halves lstat the directory before trusting it. XDG_RUNTIME_DIR is
    guaranteed by the system; the TMPDIR fallback is not, and on a shared host another
    user can create /tmp/hsql-/ first and put a listener at prod.sock. What a
    client sends is argv, the cwd and piped stdin, and argv can carry a CONN_STR with a
    password in it, so the client refuses a directory it does not own privately — and the
    server does the same, since os.mkdir(mode=0o700) does nothing to a directory that
    already exists.
  • SO_PEERCRED / LOCAL_PEERCRED uid check on accept. Reject and log any peer whose
    uid is not the server's. Belt and braces over the directory mode, and the thing that
    makes a shared /tmp misconfiguration fail closed.
  • --max-lifetime (default 8h) and --idle-timeout (default 30m,
    0 to disable). A credential held forever because someone opened a tmux tab in March is
    the predictable bad outcome; these two make the default outcome "it goes away."
  • No secrets in --session-status. It reports the adapter, the session name, the
    redacted connection identity, uptime, request count, and current state — through
    harlequin.redact and the declarative secret= that shipped with #667, not through a
    hand-written allowlist.
  • An SSH tunnel is part of the credential. A session opened with --ssh-host holds an
    ssh process and a local listener for as long as it runs, and --max-lifetime is what
    bounds that too.

4.8 Windows — and WSL2, which is not the same question

socket.AF_UNIX is not available in CPython on Windows, through 3.14. The alternatives
are a named pipe with a hand-built DACL, or loopback TCP with a token — the first is real
work with a security model that has to be got right on a platform none of the maintainers
run daily, and the second is the thing §4.7 rejects.

v1 is POSIX-only. hsql --serve on native Windows exits 2 with a message saying so, and
HSQL_SESSION on native Windows warns once and runs cold — which is exactly the
ambient-fallback behavior §4.1 already specifies, so nothing special is needed on the client
side. A named pipe transport is a clean later addition behind the same protocol.py, and
pretending otherwise in v1 would mean shipping a weaker security model to every platform to
accommodate one.

WSL2 gets the feature, because WSL2 is Linux. It runs a real kernel in a VM, its Python
is a Linux CPython with AF_UNIX present, and a server and client both inside it need
nothing special — no branch, no fallback, no separate transport. That matters more than it
sounds: the Windows users most likely to want a warm session for an agent are already
working inside WSL, so "POSIX-only" costs much less than the phrase implies, and the docs
should say native Windows rather than Windows everywhere so nobody in WSL concludes the
feature is not for them.

Two WSL-specific notes, both of which §4.4's path derivation already handles:

  • Do not put the socket on a DrvFs/9p mount. Binding a unix socket under /mnt/c does
    not work. The socket lives in the runtime dir, so this is right by construction — but it
    is one of the reasons §4.4 keeps --session-socket PATH out of v1.
  • XDG_RUNTIME_DIR is often unset under WSL2, since systemd is opt-in there. That is
    exactly the fallback branch §4.4 specifies (a 0700 hsql- directory under
    TMPDIR), and it is not hypothetical — it is unset in the container these
    measurements were taken in, too. The fallback is the common path, not the exotic one, and
    should be tested as such.

What does not work, and cannot be made to: a native-Windows hsql.exe reaching a
session running inside WSL2. That is not a socket-routing problem to solve — they are two
different Python installations on two different filesystems, and the Windows one has no
AF_UNIX to offer regardless. Stay on one side of the boundary.

4.9 Lifecycle, and the four ways this goes wrong

Failure Detection Behavior
Stale socket (server died, file remains) connect() → ECONNREFUSED client reports it and falls back or fails per §4.1; the server unlinks a stale file when it starts, because a running one also refuses between its bind() and its listen(), and on the BSDs whenever its backlog is full — a client that deleted the file on one refusal would take a live session away from every caller after it
Version skew (server 2.13, client 2.14) server sends its version in the handshake; the client compares against a literal in protocol.py refuse, exit 2, "restart the session" — never serve, because output bytes are the API and two versions may not agree on them
Server busy shutting down server stops accepting before it closes the connection client sees a clean refusal, not a truncated frame
Client dies mid-query EPIPE on write server finishes or cancels the request, drops the response, keeps running

The version check is stricter than it needs to be on purpose. hsql's output format is
documented as frozen, but "frozen" has always meant "across releases we intend"; a server
from a different release serving bytes a caller attributes to the installed version is a
category of bug nobody will diagnose quickly.

A literal, and not importlib.metadata.version("harlequin"), which costs ~43ms —
nearly twice the whole warm invocation. That makes it the second version uv version
cannot reach, beside the plugin manifest, so scripts/bump_plugin_version.py became
scripts/bump_versions.py and writes both; a test pins each to the installed version, so a
release that forgot fails in the release PR rather than in a user's shell.

4.10 Observability

hsql --session prod --session-status returns JSON on stdout:

json
{"session":"prod","pid":8123,"version":"2.13.0","adapter":"duckdb",
 "connection":"/home/ted/warehouse.db","connection_options":{"read_only":true},
 "uptime_s":412,"requests":37,"state":"idle","queued":0,
 "transaction_mode":null,"ssh":null,"idle_timeout_s":1800,"expires_in_s":1788.4}

connection_options is the rest of §4.4's identity — what a request's own
connection options are compared against — masked by whatever each adapter declared
secret=. It holds what the session was given: an option it never named is one
its adapter defaulted for itself, which core cannot enumerate, so read_only appears
only for a session started with it. state is idle, busy, or unavailable for a
session whose connection a reset has to bring back. idle_timeout_s is the idle
clock as it was set, and expires_in_s the sooner of the two countdowns — with
the idle one left out while a request holds the connection, since a session in
the middle of a query is not idle for any length of time yet. Either is null
where the clock behind it is off.

Answerable while a query is running (§4.5), which is what makes "is it hung or is it slow"
a question with an answer. transaction_mode is on it because §5 says it has to be.

5. The thing that isn't speed: a session is state

Everything above is mechanism. This section is the actual product decision.

A cold hsql invocation is hermetic: fresh process, fresh connection, no memory of the
last one. Every existing hsql behavior, doc sentence, and test assumes that. A warm
session is the opposite, and the difference is observable in ways a caller will not expect:

  • Temp tables persist. create temp table t as select ... in one invocation is
    queryable by the next. This is the single most useful thing about the feature, and it is
    the one nobody will read the docs to discover.
  • Session settings persist. SET, search_path, SET TIME ZONE, DuckDB PRAGMAs,
    installed extensions. A SET in invocation 3 changes the results of invocation 12.
  • In-memory databases persist. hsql --serve scratch :memory: is a scratch warehouse
    that survives between calls — genuinely good, genuinely a semantic change.
  • Transactions persist, and this is the sharp edge. A caller who runs BEGIN and then
    exits has left an open transaction holding locks, and every subsequent request in that
    session runs inside it. In a cold invocation the process exit rolled it back. Nothing
    rolls it back now.

Three responses, and I would take all three:

  1. Never on by default, never auto-started, never a silent fallback. All three are
    already in §4.1. This is the reason for them: the feature changes semantics, so it must
    be something a caller asked for.

  2. The server reports transaction state whenever it is not the default — on stderr
    after the request, and in --session-status. HarlequinConnection.transaction_mode
    already exists on the contract, so this costs nothing and turns the worst failure mode
    into a visible one. A session sitting in an open transaction is a thing the caller
    should be told about every single time, not once.

    This premise does not hold, and PR 5 shipped the narrower thing instead.
    transaction_mode is the adapter's Auto/Manual setting — a plain attribute that
    only toggle_transaction_mode() moves, which hsql never calls — so it says nothing
    about whether a transaction is open, and a begin left open is silent. The worst
    failure mode is therefore not visible, and --session-reset (item 3) is the only
    thing in this design that answers it. Detecting an open transaction needs a new
    member on the adapter contract, which is §7's "distinguished cancellation signal"
    shape of change: additive, ecosystem-wide, and not this feature's to make. §9's PR 5
    entry has what was shipped in its place.

  3. hsql --session prod --session-reset rolls back, closes, and reconnects, without
    restarting the process (so the imports are still warm). This is the escape hatch for
    "the agent left the session in a weird state," and it is a much better answer than
    asking someone to find and kill a pid.

What I would not do is reset between requests. It would make the server semantically
identical to the cold path, which sounds safe until you notice it also throws away the
half of the win that is connect() — and against a warehouse, that is the larger half.

5.1 Documenting it as a session, not as a cache

The docs framing follows from this: hsql --serve is not "hsql, but faster." It is "a
database session you can send commands to."
The speed is a consequence. A caller who
holds the second model in their head will predict the temp-table behavior correctly; a
caller who holds the first will file a bug.

6. Testing: byte-equivalence, but only where it is true

The risk this feature carries is not that it fails, it is that it drifts — that in some
corner (a NULL, a -t footer, an error's exit code, a truncation notice) the warm path and
the cold path produce different bytes, and a caller who set HSQL_SESSION in their
environment months ago gets a different answer than the docs describe.

Byte-equivalence is the guard for that, and it is a claim about one invocation, not about
a test.
Two invocations against a session are supposed to diverge from two cold ones —
that is §5, and it is the feature. A parametrization that ran the whole suite both ways and
demanded identical bytes would fail on every test that invokes hsql twice, and the
tempting fix — quietly exempting those — would put the divergences nobody has thought about
in the same bucket as the ones we designed. So the guard has to be stated with its scope on
it:

Equivalence holds per invocation, from a fresh session. Given the same starting state,
one hsql invocation produces the same bytes and the same exit code whether it ran cold
or warm. Nothing is claimed about what the next invocation sees.

That precondition has to be manufactured, because the cold path gets it for free (a new
process every time) and the warm path does not. Each test gets a fresh session — one
server per xdist worker, --session-reset between tests — which is cheap, since a reset is
a reconnect and not a re-import. Pleasingly, it makes the test suite the primary consumer of
the escape hatch §5 proposes for humans, so the thing that rescues a wedged session is
exercised hundreds of times per run rather than never.

Three kinds of test, then, not one:

  1. Equivalence — every single-invocation functional test, run twice, asserting identical
    stdout bytes, identical stderr bytes, identical exit code. The golden-format snapshots
    (test_golden_formats.py) come along for free, since they are single-file syrupy
    snapshots written in binary and already assert on bytes. This is the drift guard, and it
    is the suite that runs on every PR if only one can.
  2. Divergence, marked and asserted — a multi-invocation test cannot be in group 1, and a
    marker (@pytest.mark.session_divergent, registered in [tool.pytest.ini_options]
    beside online, use_cache and py12) takes it out. The marker's cost is that it does
    not merely opt out: the test must assert what the warm behavior is, not just that it
    differs. A temp table created by invocation 1 is no such table cold and a result set
    warm, and both belong in the assertions. Otherwise the exemption list becomes the place
    bugs live, which is exactly what the exemption exists to prevent.
  3. Session semantics — tests that only exist on the warm path, because they are the
    feature: state surviving between invocations, an open transaction being reported, a reset
    clearing it, two clients queueing, a session refusing a client whose connection options
    differ.

The marker earns its keep twice over, because the set of tests carrying it is an
executable version of §5.
§5's list of what statefulness changes is prose today; the
marker turns it into an enumerated one that cannot silently grow. A contributor who
introduces a new divergence has to mark a test, and the marker is the review trigger that
asks whether §5 should have mentioned it.

Alongside those:

  • An import-linter contract, next to the existing hsql does not reach the TUI one,
    saying the client's three modules may not import click or anything of Harlequin's
    outside themselves. Its other half is not expressible there — a contract's source
    module matches its descendants, so "harlequin.hsql.__init__ may not import click at
    module scope" cannot be written — and is a run-time test instead. Both shipped in PR 1.
  • A cold-start benchmark for the client, next to the existing one for hsql
    (scripts/cold_start.py), plus a run-time test that counts the client's modules against
    a bare interpreter — a count, not a wall clock, because the count is what a shared CI
    runner can hold. Both shipped in PR 1.
  • Deterministic lifecycle tests, not timing ones: stale socket, wrong uid (skipped
    unless the test runner can drop privileges), version skew, a client killed mid-query, two
    clients queued, a cancel that lands, a cancel against an adapter that cannot cancel.

One trap worth naming, since the suite runs under -n auto: a session-scoped server
fixture is cross-test contamination waiting to happen.
A test that runs SET or leaves a
transaction open changes the result of an unrelated test that lands on the same worker
later, and it will present as a flake with no obvious cause. The per-test reset above is
what prevents it, and it is the reason the fixture must not be session-scoped for
convenience.

7. What's cut, and why

  • Auto-start. A client that spawns a server when it does not find one is the obvious
    ergonomic win and the obvious way to end up with orphan processes holding warehouse
    credentials that nobody remembers starting. It is also the feature that is easy to add
    later and impossible to remove
    , once scripts depend on it. Ship explicit-only, watch how
    people actually set sessions up, then decide. If it does land, it should be
    --session-autostart and it should inherit the idle timeout, not the max lifetime.
  • Windows. §4.8.
  • TCP, remote servers, containers-talking-to-hosts. AF_UNIX and a shared filesystem
    are assumptions of §4.3's -o handling as well as §4.7's security model. Removing them
    is a different product.
  • Connection pooling / parallel requests. §3.4. A read-only pool is a plausible later
    addition, but only for --read-only sessions, where "which connection" is unobservable.
  • Multiple databases per server. One session, one connection. Two databases is two
    --serve processes, and they cost 200MB of RSS each at most, which is not the
    constraint here.
  • A session for the TUI. harlequin --session prod sounds appealing and is not: the
    IDE holds its own connection for its whole run, so it has nothing to gain, and it would
    put the TUI's evolving surface on the frozen protocol.
  • A distinguished cancellation signal on the adapter contract (§4.6). Real, and an
    ecosystem change; it belongs with M2's additive members, not here. The server's own
    bookkeeping is a correct workaround in the meantime.
  • A compiled client. 16ms of the 27ms is CPython boot, so a Rust or Go client would
    land near 2ms. It also means platform wheels for a project that ships pure Python, and
    it optimizes the half of the budget that is already smallest. Revisit only if someone is
    running thousands of invocations.

8. Risks

  • A second execution path is a second product. The mitigation is structural, not
    disciplinary: the server calls build_cli() and the same query/output code, and §6's
    equivalence suite fails if the bytes diverge. If the server ever grows its own
    formatting or its own flag handling, that is the signal the layering went wrong — the
    same signal the product plan already names for MCP.
  • The credential-held-open surface. §4.7 is the mitigation and it is not complete:
    anything running as the same uid can use the session. That is the same trust boundary as
    the user's ~/.ssh and their Harlequin config, and it should be stated in the docs in
    those terms rather than implied.
  • Statefulness surprising someone. §5. The transaction reporting is the mitigation I'd
    least want cut.
  • It overlaps M6. §3.3 argues that is a reason to build this first and land MCP on top.
    If M6 is imminent, the sequencing question is real and should be settled before PR 1 —
    building both connection lifecycles would be the expensive mistake.
  • A lifetime expiry mid-query leaves that query unreachable. The expiry path is the
    stop signal's, which closes the listener and unlinks the socket before it drains: the
    request in flight is still answered, but for the rest of its run no cancel and no
    --session-status can reach it, because both open a second connection to a path that
    is gone. Under SIGINT an operator is present and knows what they just did; under
    --max-lifetime it happens by itself, at eight hours, to whoever is mid-query. Draining
    before the unlink would need care around a client racing in on the still-live socket,
    so PR 5 left the ordering alone — but the docs should say that a lifetime expiry mid-query
    leaves the query uninterruptible until it finishes.
  • Idle servers as an operational nuisance. Idle timeout defaults to 30m for this
    reason. Expect the first bug report to be "my session died between calls"; the answer is
    --idle-timeout 0, and the docs should say so before the report arrives.
  • Nobody uses it. The honest risk. The feature is only reachable by someone who set it
    up deliberately, which §5 says it must be. Mitigations are the docs framing, a
    SessionStart-hook example, and the fallback warning — which is the one place a caller
    who should be using a session will be told it exists.

9. Shipping sequence

Six PRs, in dependency order. The first two are the ones with the interesting decisions in
them; everything after is additive and independently revertible.

  1. The entry-point split and the framing. Shipped. main() decides whether an
    invocation belongs to a session before it imports anything that is not stdlib;
    session.py (the decision, and the socket path), client.py (stdlib only) and
    protocol.py (chunked frames, handshake, version). No server yet — the client's only
    reachable behavior is the fallback path, which is testable on its own, and the rest is
    pinned against a stub server built on protocol.py. Ships the import-linter contract,
    the run-time guards, and the client's steps in scripts/cold_start.py.

    Two things moved out of it, both deliberately. --session is not yet a click
    option
    : the client intercepts it before click, so it works, but a flag --help and
    --spec advertise while no server can answer it would be worse than one they do not
    mention yet. And declaring it raises a question PR 2 has to answer anyway — a click
    parameter's name becomes a profile key, and session must not be one, because the
    decision is made before any config file is read. That is a CLI_ONLY_SSH_KEYS-shaped
    refusal, and it belongs with the flag. Until then no adapter reserves the spelling
    either; none of the four checked declares it (§4.1).

  2. The server: accept loop, one worker, the queue, the uid check, socket lifecycle.
    hsql --serve NAME, the --session click option and its profile-key refusal,
    HSQL_SESSION, and the §4.1 flag partition on both sides. Ships §6's equivalence suite
    with it, not after it — this is the PR where equivalence is cheap to establish and
    expensive to retrofit.
    --session-reset lands here too, ahead of the rest of the lifecycle work: §6's
    fixture needs it to give each test a fresh session, so it is a dependency of the suite
    rather than a nicety, and the suite is the thing that proves it works.

  3. Identity and rejection. Shipped. The server's recorded connection identity, the
    differing-options rejection, client-side profile resolution against the client's cwd
    (§4.2), --session-status. One question this PR settled that the first draft did not
    ask: whether HARLEQUIN_CONFIG_PATH travels with the request. It does. It is the
    caller's intent in the same way --config-path is, and a session that honored the flag
    and ignored the variable would read a config file neither of them named — which is the
    same failure §4.2 forwards the client's cwd to avoid. It widens
    protocol.FORWARDED_ENV_VARS past "what --color auto needs", and the line that
    replaces PR 1's is narrower than it looks: the two variables on it name where a value
    comes from
    rather than being one, and neither is a credential.

    Two things this PR settled that the plan did not name. --session-status is not a
    request
    , because §4.5 promises it is answerable mid-query and a request cannot be:
    a served invocation borrows the process's cwd, environment and streams for its whole
    run, so a second one parsed alongside it would take them out from under a query. So
    the client sends it as its own frame — a third thing its argv scan knows about — and
    the server answers it off its own bookkeeping, which is the shape PR 4's cancel wants
    anyway. It stays a declared click option so that --help, --spec and the partition
    still describe it, and so a cold invocation is refused rather than ignored; and it
    joins CLI_ONLY_SESSION_KEYS, because a profile could otherwise set it only for the
    runs that never reach a session. The status's transaction_mode is null while the
    session is busy
    : reading it means asking a driver a question on a second thread,
    which is the thread-safety §4.5 refuses to assume. idle_timeout_s and expires_in_s
    are null until PR 5 can answer them, rather than absent, so the document's shape does
    not change under a caller.

  4. Cancellation. Shipped. SIGINT → cancel frame → connection.cancel(), the
    IMPLEMENTS_CANCEL = False path, and the DuckDB fetchall() -> None attribution.

    Two things this PR settled that the plan did not name. The request id is the
    client's, not the server's
    : a server-assigned one would have to reach the client
    before its query started, and the only frame ahead of a request is the handshake,
    whose shape has to stay readable by every release for the §4.9 version check to
    work — so it is a sixth section of the request, eight bytes from os.urandom().
    And a request is cancellable from the moment its argv arrives, not from the moment
    it takes its turn.
    A caller who gives up while their request waits behind the one
    ahead is cancelling something the session is holding, and nothing else would stop it
    running when its turn came.

    That makes the load-bearing invariant one about windows, because
    HarlequinConnection.cancel() is connection-wide: the window in which a request is
    registered and marked started has to be exactly the window in which it holds the
    turnstile. Widen it at either end and a cancel naming a request that has already
    finished interrupts whichever query holds the connection now — and marks that one's
    bookkeeping not at all, so the empty, error-free result the interrupt produces is one
    nobody can attribute and the caller reads as an empty table. Two things keep the
    windows equal: the server releases a request from its bookkeeping before it leaves
    the turnstile, and cancel() holds the lock a request takes to start across the
    driver call, so nothing can start between the check and the interrupt. The client's
    half of the same rule is that the send and the relay are under one try, or an
    interrupt landing between them exits 130 having sent no cancel at all.

  5. Lifecycle and state hygiene. Shipped. --idle-timeout, --max-lifetime,
    transaction-mode reporting, and the secret-on-a-server-command-line warning (§4.1).

    Three things this PR settled that the plan did not name. A status poll is not
    use of a session
    , so it does not touch the idle clock: an agent that watches a
    session it has stopped sending queries to would otherwise keep the credential
    open by watching it, which is the outcome the clock exists to prevent. The clock
    is therefore held while a client is connected rather than reset when one
    arrives — which is also what keeps a session from stopping under a client that
    has connected and not yet said what it wants, since a -f - reads its script
    after the handshake and nothing else can see such a client. (Review caught that:
    the first cut counted parsed requests alone, and a slow producer upstream of
    -f - got the socket unlinked under it.) The
    idle clock does not run while a request does
    , and the lifetime clock does —
    the first is about a session nobody wants and the second bounds the credential,
    so a query that takes an hour is not idle and is not exempt either; a lifetime
    that runs out mid-query stops the accept loop and lets the request in flight
    finish, which is the path a stop signal already took, at the cost §8 now records.
    And the default a
    transaction mode is compared against is the one the session connected in
    ,
    re-read on every --session-reset, because the contract offers no way to ask an
    adapter which of its modes is its default. Review then found the premise above it
    does not hold either: the mode is a setting nothing hsql runs can move, so what
    shipped reports a session moved out of band and makes no claim about open
    work — §5 item 2 has the correction. A narrow feature honestly described, rather
    than a safety net that is not there.

  6. Docs. Shipped. /docs/hsql/sessions on harlequin.sh, written per §5.1, with the
    SessionStart-hook example on it. Not optional and not last in spirit — a feature that
    must be deliberately adopted is a feature that lives or dies by its docs.

    One thing this PR settled that the plan did not name: the hsql --help mention was
    already there
    , since the flags became click options in PR 2 and the epilog gained a
    Sessions block with them. What the docs owed instead was the two things --help cannot
    hold: what a session remembers (§5), and the three pages that were true only of a cold
    invocation — psql.md's "no session variables" and "No Interactive Session", and the
    tutorial's every-invocation-connects framing.

M2 has shipped, and M3 with it. M4 is in flight in three releases and touches cli.py
elsewhere (--open, the hooks, query history), so sequencing against it is a scheduling
question rather than a technical one. The one real ordering constraint is still §3.3's:
settle the relationship with M6 first.


10. What this revision changed

For a reader who has the first draft in their head. Nothing about the shape of the design
moved; the changes are the arithmetic, the flags that shipped in between, and three things
the first draft got wrong about the entry point.

  • Re-measured on 2.13.0 (§1, §2). The machine is slower than the first draft's, so the
    absolutes moved and the ratios did not: 348ms cold, 27ms warm, and the database work is
    5ms of the 348.
  • §2 is the shipped client, not a prototype. PR 1's numbers replace the throwaway's.
  • The entry point had three imports on it, not one (§2.1). click was the known one;
    harlequin.hsql.diagnostics (68ms, via redact → config → msgspec) and typing
    (10ms, via harlequin/__init__.py) were not. Together they were more than twice the
    invocation they sat on.
  • One import-linter contract, not two (§2.1, §6). The second is not expressible; it is
    a run-time test.
  • The socket path is derived with stdlib, not platformdirs (§4.4), which costs ~22ms.
  • The version literal lives in protocol.py, and a release script writes it (§4.9).
  • M2's flags are no longer conditional — --read-only, --timeout, the mode options
    and declarative secret= all shipped, and --timeout's implementation in
    hsql/timeout.py is the precedent PR 4's cancellation attribution should follow (§4.6).
  • The SSH options are in the partition (§4.1), as connection-time, and the tunnel is
    part of what §4.7 calls a credential held open.
  • --session waits for PR 2 to become a click option (§9); PR 3 answered the
    HARLEQUIN_CONFIG_PATH question with "it travels."

Plans M1 Hsql Technical Plan (`docs/plans/m1-hsql-technical-plan.md`)

M1 Technical Plan — `hsql`, the headless CLI

Implementation plan for milestone M1 of Harlequin for agents.
That document is the product spec: what hsql is, why it's a separate command, and what
its contract with an agent looks like. This one is about how we build it, what has to be
refactored first, and in what order it can ship.

M1's scope, from the roadmap: second console script; extract the shared execution
core; import-linter rule and cold-start benchmark in CI; -c, -f, stdin, -o, -F +
shorthands, default --limit 500, truncation notices, --stats, exit codes,
--on-error, --color/NO_COLOR, plain errors; unknown-option hint on harlequin;
the seeded "Headless & Agents" docs topic. Closes
#524.

Bottom line up front. A working prototype of the refactors below runs a
select … union all select … against DuckDB — interpreter startup, config discovery,
adapter load, connect, execute, fetch, normalize, render — in 220–242ms with Textual
never imported
, down from a 1335ms floor today. Nothing in M1 needs new normalization
or parsing code: the two hard pieces already exist in textual-fastdatatable and
tree-sitter-sql, and both can be reached without Textual.


1. Where the code actually is today

Everything below was measured on this repo at 2.8.0, not estimated.

1.1 There is no execution core to share

Query execution lives entirely inside the Textual app, as two threaded workers that
communicate by posting messages:

  • Harlequin._execute_query (src/harlequin/app.py:1077) — loops over statements,
    calls connection.execute(q), applies cur.set_limit(...), collects cursors, posts
    QueriesExecuted.
  • Harlequin._fetch_data (src/harlequin/app.py:1154) — drains each cursor with
    fetchall() + columns(), times the batch, posts ResultsFetched.

There is no importable function. hsql cannot call any of this, and a naive copy is
exactly the "two products" failure §12 of the product plan warns about.

The split into execute all, then fetch all is deliberate and good — it's what lets the
TUI show "query executed" before results materialize. The extracted core should preserve
that shape rather than flatten it, so the TUI can adopt it without changing its
concurrency model.

Harlequin._split_query_text (app.py:1135) is a naive text.split(";"). It is dead
code
— nothing calls it. The live splitter is CodeEditor.selected_queries()
(components/code_editor.py:36), which uses tree-sitter through textual-textarea. See
§3.2 — the grammar it uses is reachable without any of that.

1.2 Cold start is 1.3 seconds, and the reasons are structural

text
$ time harlequin --version
harlequin, version 2.8.0
real    0m1.335s

That is the cheapest possible invocation of the current CLI. The 300ms target is not a
matter of shaving milliseconds; five separate things each pull in the whole TUI:

# Cause Cost
1 harlequin/__init__.py imports harlequin.app at module scope, so importing any harlequin.* submodule imports Textual, sqlfmt, prompt_toolkit and pyarrow 660ms floor
2 harlequin/adapter.py:7 imports textual_fastdatatable.backend for AutoBackendType, which is Any 265ms
3 textual_fastdatatable/__init__.py imports the DataTable widget, so the backend can't be reached without Textual 58ms + 158 modules of the 265ms above
4 harlequin/options.py imports questionary, textual.validation, textual.widget and harlequin.copy_widgets at module scope, for to_questionary() and to_widgets() 187ms
5 build_cli() calls load_adapter_plugins(), which does ep.load() on every installed adapter to reach its ADAPTER_OPTIONS 160ms for 4 adapters, unbounded

Cause 2 recurs in the adapters themselves: harlequin_duckdb/adapter.py:14 and
harlequin_sqlite/adapter.py both import textual_fastdatatable.backend at module
scope, so even a perfectly clean hsql would pay for Textual the moment it loaded an
adapter.

harlequin/catalog.py:6 imports textual.message.Message for NewCatalog and
NewCatalogItems — two TUI-only messages sitting in a module every adapter imports.
Neither is used outside app.py and components/data_catalog/database_tree.py.

1.3 `textual-fastdatatable` is ours, and its backend has no Textual in it

We control this package, which changes the answer to the hardest problem in M1 (§3.1).
Measured on the installed 0.16.0:

  • backend.py, format.py, and column.py contain zero references to Textual. The
    imports are pyarrow and rich.
  • The only thing that pulls Textual is textual_fastdatatable/__init__.py importing
    data_table.DataTable.
  • With that __init__ made lazy: from textual_fastdatatable.backend import create_backend
    drops from 265ms/359 modules/Textual to 217ms/201 modules/no Textual.
  • Most of the remainder is pyarrow.parquet, imported at module scope but used only by
    ArrowBackend.from_parquet. Deferring it into that classmethod takes the import to
    ~140ms/183 modules.
  • textual>=7.3.0 is a required dist dependency. Nothing in the backend uses it. A
    future lean hsql install would need that made an extra.

1.4 tree-sitter is already installed, and usable without Textual

textual-textarea requires textual[syntax], which brings tree-sitter and
tree-sitter-sql. The editor's separator query is one line
(code_editor.py:22): (";" @semicolon).

Driving that grammar directly, with no Textual and no textual-textarea, measures at
28ms to import, 92 modules, and 22ms to split a 2000-statement script. It gets
string literals, line comments, block comments, quoted identifiers and unicode right.
It gets $$-dollar-quoting wrong — and so does the TUI today, because it's the same
grammar and the same query.

Two further things fell out of driving it directly. Tree-sitter is used in exactly one
place in Harlequin — that one query — and nowhere else. And its Point.column is a
byte offset, which the editor feeds to a function expecting characters. See §3.2.

1.5 Two places already write results to stdout that shouldn't

  • harlequin/plugins.py:54 — a bare print() when a plug-in fails to import. Under
    hsql -F csv > out.csv that lands in the CSV.
  • harlequin/exception.py:68 pretty_print_warning — uses rich.print, i.e. stdout.
    Reached from locale_manager.py and windows_timezone.py, both on the CLI path.

pretty_print_error already gets this right, with a comment saying why. The contract
exists in intent; M1 makes it true.

1.6 Export is coupled to a widget

harlequin/export.py:20 copy() takes a ResultsTable (a Textual widget) and reaches
into table.backend.source_data. The five exporters underneath it are pure
pa.Table → file. The column-deduplication step in between is data logic wearing a
widget's clothes.

1.7 The prototype

I applied the §1.2 fixes plus the §1.3 upstream fixes and ran a script that does what
hsql -c will do — config discovery, single-adapter load, connect, execute with
set_limit(limit + 1), create_backend(data, max_rows=limit), write CSV to stdout:

text
220ms  238ms  242ms      textual=False   306 modules

With load_adapter_plugins() left as-is (all four installed adapters imported) the same
script takes 377–398ms — which is cause 5 measured in situ, and the reason §3.4 is not
optional. from harlequin import Harlequin and from textual_fastdatatable import DataTable
both still work after the changes; those are load-bearing for third-party adapters and
for the TUI respectively. All patches were reverted.


2. The four obstacles, stated plainly

  1. No execution core exists. It has to be extracted from the app before hsql can
    have one, or we ship two implementations on day one.
  2. Every import path leads to Textual — in this repo and one level upstream.
    Fixable, cheap, measured; it must land first, because it's also the guard that keeps
    the fix from silently regressing.
  3. Building the CLI requires importing every installed adapter. The only unbounded
    cost, and the only one that gets worse as the ecosystem grows.
  4. Text layout doesn't exist, and is coupled to a widget where it does. The TUI
    renders into a DataTable; export.py writes files but only from a ResultsTable.
    Nothing arranges a result as table, markdown or vertical text.

Note what is not on this list, though earlier drafts of this plan had all three:
normalizing adapter output, parsing SQL into statements, and serializing values. Each
looked like a new subsystem and turned out to be an existing one we already own —
create_backend, tree-sitter-sql, and duckdb respectively. The residual work in M1 is
smaller than it first appears, and the parts that remain are the parts that are genuinely
ours: the CLI, the execution flow, and three text layouts.


3. Target architecture

Six new or reshaped libraries, plus one upstream change. Each owns a domain and has a
stated interface; none of them is a bag of helpers.

text
textual-fastdatatable
  __init__.py     MOD  lazy DataTable, so `backend` is reachable without Textual
  backend.py      MOD  pyarrow.parquet deferred into from_parquet()

harlequin/
  statements.py   NEW  locating statement boundaries in SQL text
  query.py        NEW  execute SQL, get results, without a UI
  layout.py       NEW  arranging already-serialized text for a reader
  export.py       MOD  writing a result set to a file, serialized by duckdb/pyarrow
  config.py       MOD  + merging CLI values over a profile
  plugins.py      MOD  + naming and loading one adapter without importing the rest
  options.py      MOD  renderer imports deferred; declaration stays import-light
  hsql/           NEW  the command
    __init__.py        main()
    cli.py             the click command and its two-phase adapter resolution
    diagnostics.py     everything written to stderr, and the code we exit with

The one-line statement of the architecture, which is also a testable rule:

stdout is written only by harlequin.layout and harlequin.export. stderr is
written only by harlequin.hsql.diagnostics.

3.1 `harlequin.query` — the execution core, over the existing backend

python
@dataclass(frozen=True)
class Statement:
    sql: str
    index: int  # 0-based position in the submitted script


@dataclass(frozen=True)
class RowLimit:
    max_rows: int | None  # None = unlimited
    detect_overflow: bool  # fetch one extra row so truncation is knowable


@dataclass
class ExecutedStatement:
    statement: Statement
    cursor: HarlequinCursor | None  # None for DDL/DML


@dataclass
class ResultSet:
    statement: Statement
    columns: list[tuple[str, str]]  # (name, short type) from cursor.columns()
    backend: DataTableBackend  # from textual_fastdatatable
    truncated: bool
    elapsed: float

    @property
    def row_count(self) -> int: ...  # backend.row_count
    def rows(self) -> Iterator[Sequence[Any]]: ...
    def text_columns(self) -> pa.Table: ...  # every value CAST AS VARCHAR; see §3.3


def execute(
    connection: HarlequinConnection,
    statements: Sequence[Statement],
    limit: RowLimit,
    on_error: Literal["stop", "continue"] = "stop",
) -> Iterator[ExecutedStatement]: ...
def fetch(executed: ExecutedStatement, limit: RowLimit) -> ResultSet: ...

ResultSet wraps a DataTableBackend rather than reimplementing one.
HarlequinCursor.fetchall() returns AutoBackendType, which is Any — in practice a
pa.Table (duckdb), a RecordBatch, a Sequence[Iterable] (sqlite), a
Mapping[str, Sequence], or a polars or pandas frame. create_backend() already
normalizes all of those, it is the normalization the TUI uses, and per §1.3 it needs no
Textual to run. Writing a second normalizer for hsql would put the most drift-prone
code in the codebase — "what counts as a row, what counts as null, how does a nested
struct come out" — in two places, and the disagreement would surface as hsql and the
TUI showing different data for the same query. That is the worst possible place for
these two front doors to diverge.

Superseded, after M1 shipped. The collision described below was resolved by
#1026: the display cap has its own
key, viewer_max_rows, limit is the hard fetch limit in both commands, and the text
layouts grew a soft cap of their own (--display-rows). harlequin --limit now fills
in the Run Query Bar's limit and checks it, so the TUI does detect overflow — the
premise below, that it always fetched every row, stopped holding the moment a user
checked that box, and no longer holds at start-up either. See AGENTS.md for the
current vocabulary. The rest of this section is the reasoning as it stood.

Harlequin already has two different limits, and hsql wants the other one. This is
worth spelling out, because the names collide and I had it wrong in an earlier draft:

What it does Where
Soft, display — harlequin --limit, max_results, default 100,000 Fetches everything, caps what's loaded into the viewer results_viewer.py:147, create_backend(max_rows=…)
Hard, fetch — the Run Query Bar's limit checkbox cur.set_limit(n), so fewer rows leave the database app.py:1093

The soft limit is why the TUI needs no overflow detection and never did: because it
fetched every row, it knows the exact total and already says so —
"(Showing 100,000 of 3,412,887 Records)" at results_viewer.py:234. That's strictly
better than anything an inference could give it. My earlier suggestion that it show
"100,000+" was wrong.

hsql -l is the hard limit. Fetching a million rows to print five hundred would
defeat the entire point of a limit for an agent — time, memory, and egress. There is also
no viewport headless, so the soft limit has no meaning: hsql has exactly one limit
concept, and it's the fetch-side one. Same flag name, different semantics between the two
commands; the docs need one sentence about that rather than a pretense that they match.

Which is why truncation needs limit + 1. set_limit(n) then fetchall() returns at
most n rows and tells you nothing about whether an n+1th existed; exactly n is ambiguous.
So execute() requests limit + 1 and fetch() calls create_backend(data, max_rows=limit).
DataTableBackend already distinguishes row_count (capped) from source_row_count
(what it was handed), so truncated = source_row_count > row_count — the mechanism
exists, we just feed it one extra row. This is why the limit is a value object with an
explicit detect_overflow rather than an int each caller interprets: principle 5 of the
product plan ("truncation must always be announced — never silent") is unimplementable
without it, and it costs exactly one row.

The asymmetry is real and defensible: the TUI can afford to know the exact total because
it already paid for every row; hsql deliberately hasn't, so it reports 500 of >500.
Under -l 0 there's no set_limit at all, so the count is exact and --stats reports
"truncated": false against a real number.

No adapter-interface change for limits in M1. The clean long-term answer is for the
cursor to say whether more rows exist — a has_more(), or a set_limit() that reports
back — instead of us inferring it from an extra row. That's a fifteen-adapter rollout for
a problem the existing API already solves, so it waits. set_limit() and the backend's
max_rows are both existing features, used as they are; the only thing hsql does
differently is pass limit + 1. If a later milestone adds a real capability, RowLimit
is the single place that changes.

Upstream first. The two textual-fastdatatable changes in §3 are a prerequisite, and
both are non-breaking: the lazy __init__ keeps from textual_fastdatatable import DataTable
working via PEP 562 __getattr__, and deferring pyarrow.parquet is invisible to
callers. Making textual an optional extra is a bigger change — it would let a future
lean hsql install skip Textual entirely, but it's a dependency break for existing
users and buys M1 nothing, since harlequin needs Textual regardless. Deferred.

The TUI's two workers become thin wrappers: _execute_query calls execute(),
_fetch_data calls fetch(), both still post the same messages, and the DataTable gets
the same backend object it builds today.

3.2 `harlequin.statements` — one grammar, one query, two slicers

Driving tree-sitter directly (§1.4) is better than every alternative I costed: 28ms
against sqlfmt's 85ms, no new SQL scanner to maintain, and — the part that matters —
it is the same grammar and the same query the TUI already uses, so hsql -f script.sql
and the editor cannot disagree about where a statement ends.

python
def find_separators(
    text: str,
) -> list[Point]: ...  # (row, character column) past each ";"
def split(text: str) -> list[Statement]: ...  # separators -> trimmed statements

tree-sitter and tree-sitter-sql become explicit, required dependencies of
harlequin
(Ted's call). Both are installed for everyone today, transitively via
textual[syntax]; depending on them directly costs 28ms, makes the guarantee real
instead of incidental, and collapses this section from "two splitters, carefully kept in
sync" to one. The accepted cost is that Harlequin stops installing on a platform with no
tree-sitter wheel — which is why textual made it an extra, and which the
harlequin.sh/docs/troubleshooting/tree-sitter page currently exists to explain. That
page becomes obsolete; PR 7 removes it.

With that, there is exactly one splitter. Tree-sitter is used in precisely one place
in Harlequin today — code_editor.py, for the semicolon query, and nowhere else
(highlighting is textual-textarea's own business and unaffected). So the editor calls
find_separators(self.text) and slices as it does now, and we delete SEMICOLON_QUERY,
_semicolon_query, prepare_query, query_syntax_tree, has_shown_tree_sitter_error,
and the whole is_syntax_aware fallback branch.

The one thing given up is textual-textarea's incremental tree: the shared library
reparses. Measured, that's 0.6ms for a realistic buffer and 22ms for a pathological
2000-statement one, and selected_queries() runs on submit rather than on keystroke. A
worthwhile trade for deleting a divergence.

This fixes a live unicode bug in the TUI, which is why the shared library returns
character columns.
Tree-sitter's Point.column is a byte offset; Textual's own
docstring says so, and it hands the raw nodes back. But get_text_range() takes
character columns, and code_editor.py:159 passes one straight into the other. Any
non-ASCII before a semicolon on the same line shifts the cut:

text
select '日本語';select 2
  splits today  -> "select '日本語';select"  |  "2"
  should be     -> "select '日本語';"        |  "select 2"

Both halves are then syntax errors. It's narrow — it needs non-ASCII before the
semicolon and a non-space after it, so .strip() usually absorbs the off-by-one — but
it's real, reproducible, and in main right now. Owning the byte→character conversion
in one place, once, is what stops hsql from inheriting it. Worth its own issue and a
changelog line, since it's a TUI bug fix that happens to fall out of this work.

One known bug we don't fix: create function f() … $$ … ; … $$ … splits inside the
dollar-quoted body. The TUI has it today, identically, because it's the grammar's. Its
own issue, not M1 scope — and a decent demonstration that sharing the grammar means
hsql inherits the TUI's behavior including its bugs, which is the definition of no
drift.

3.3 Output — duckdb serializes, `hsql` lays out

text
/* Detailed source-code truncated for AI context efficiency. */
```python
# harlequin/export.py — writing a result set to a file, serialized by duckdb or pyarrow
def write_file(data: pa.Table, path: Path, format_name: str, options: Mapping[str, Any]) -> None: ...
def file_format_names() -> list[str]: ...

# harlequin/layout.py — arranging already-serialized text for a reader
@dataclass(frozen=True)
class LayoutOptions:
    header: bool = True
    footer: bool = True
    aligned: bool = True
    null_string: str | None = None
    color: bool = False

class Layout(Protocol):
    def write(self, result: ResultSet, out: TextIO) -> None: ...

def get_layout(name: str, options: LayoutOptions) -> Layout: ...
def layout_names() -> list[str]: ...

export.py keeps its name and its job, loses the ResultsTable coupling from §1.6, and
gains tsv and jsonl as thin option variants of formats it already writes. layout.py
is new and small: three layouts, no type knowledge, no duckdb.

LayoutOptions is what makes psql's flag algebra fall out for free: -t is
header=False, footer=False, -A is aligned=False, and -tA needs no special case
because it was never one — two independent options, which is how psql users already think
about them. --no-header and --null-string also reach export.py as header and
na_rep, so the same four flags mean the same thing in every format.

3.4 `harlequin.plugins` — naming an adapter without importing it

python
def adapter_names() -> list[str]: ...  # entry-point names only
def load_adapter(name: str) -> type[HarlequinAdapter]: ...  # imports exactly one
def load_adapter_plugins() -> dict[str, type[HarlequinAdapter]]: ...  # existing

adapter_names() reads entry-point names without calling ep.load(), so it costs
nothing. That's what makes the two-phase parse in §3.6 possible, and per §1.7 it is worth
~160ms of the prototype's runtime — the difference between 380ms and 220ms.

3.5 `harlequin.config` — merging CLI values over a profile

The merge in cli.py:315-327 (drop every parameter whose click source is DEFAULT,
then config.update(kwargs)) is the precedence rule both commands must implement
identically. It moves to config.py, which already owns Profile:

python
def merge_profile_with_cli(
    profile: Profile,
    cli_values: Mapping[str, Any],
    explicitly_set: Container[str],
) -> Profile: ...

/* Detailed source-code truncated for AI context efficiency. */

python
class ExitCode(IntEnum):
    OK = 0; QUERY = 1; USAGE = 2; CONNECTION = 3; TIMEOUT = 4; INTERRUPT = 130

def exit_code_for(error: BaseException) -> ExitCode: ...
def report_error(error: BaseException) -> None:          # "hsql: error: ..."
def report_truncation(result: ResultSet, limit: RowLimit) -> None: ...
def report_stats(...) -> None:                            # one line of JSON

Two things worth stating because they're easy to get wrong:

  • The truncation notice fires under -t. -t suppresses stdout chrome; it does not
    suppress warnings. A flag that silently defeated truncation reporting would undo the
    principle it's meant to coexist with.
  • Exit codes are hsql's contract, not Harlequin's, so the mapping lives here rather
    than in harlequin.exception. HarlequinQueryError → 1, HarlequinConnectionError → 3,
    HarlequinConfigError → 2, KeyboardInterrupt → 130.

4. Sequencing

One upstream release, then two Harlequin releases. The first Harlequin release contains
no new user-facing surface at all; the second is the feature. Every PR in the first is
independently reviewable, independently testable, and behavior-neutral for the TUI.

Upstream — `textual-fastdatatable` (0.17)

PR 0. Lazy DataTable in __init__.py via PEP 562; pyarrow.parquet deferred into
ArrowBackend.from_parquet. Add a test asserting "textual" not in sys.modules after
importing textual_fastdatatable.backend — otherwise the next contributor re-adds a
convenience import at the top of __init__ and nobody notices for a year. Both changes
are non-breaking; harlequin then bumps its pin from ==0.16.0 to ==0.17.0.

Blocks PR 2 and PR 3. Doesn't block PR 1, so it can be running in parallel.

Release A — the plumbing (2.9)

PR 1 — Import hygiene, and the guard that keeps it.
Lazy harlequin/__init__.py via PEP 562, preserving every current name.
AutoBackendType under TYPE_CHECKING in adapter.py, harlequin_duckdb,
harlequin_sqlite. NewCatalog/NewCatalogItems move to harlequin/messages.py, with
a module-level __getattr__ shim in catalog.py so existing imports keep working
without importing Textual. Deferred renderer imports in options.py; _CustomValidator
moves to copy_widgets.py, which is already the option-widgets library. Fix the two
stdout leaks from §1.5. Add import-linter contracts and the cold-start benchmark script.

Ships value on its own: importing harlequin_duckdb goes from 691ms to 56ms, which
every adapter's test suite and every library consumer feels.

PR 2 — harlequin.statements and harlequin.query. Add tree-sitter and
tree-sitter-sql to dependencies. Build the splitter; refactor CodeEditor onto it and
delete its fallback branch, fixing the unicode column bug in the same move; land the
tricky-SQL corpus. Then the execution core, with _execute_query and _fetch_data
refactored onto it. Delete the dead _split_query_text. Snapshot tests are the safety
net for the TUI refactor.

This is the only PR in Release A with user-visible behavior in it — the unicode fix — so
it gets a changelog entry and its own issue while the rest stay silent.

PR 3 — export.py decoupled from ResultsTable, plus harlequin.layout. Extend
export.py with tsv and jsonl (option variants of formats it already writes) and the
temp-file-to-stream path; add the three layouts and ResultSet.text_columns(). Golden
files per format. No user-visible change; neither has a consumer until PR 5.

PR 4 — merge_profile_with_cli, adapter_names, load_adapter. harlequin's own
CLI refactored onto the first; behavior-neutral, covered by the existing 392 lines of
test_cli.py.

Why these land on main rather than accumulating on a feature branch: they're
behavior-neutral and individually tested, and the alternative is a long-lived branch
rebasing against a moving TUI — which is where extraction refactors go to die.

Release B — the command (2.10)

PR 5 — hsql. Console script, two-phase parse, -c/-f/stdin, -o, -F and the
shorthands, -t/-A/--no-header/--null-string, -P, -l defaulting to 500,
--result, --on-error, --color/NO_COLOR, --stats, exit codes, truncation
notices, plain errors.

PR 6 — Discoverability. Unknown-option handler on harlequin (-c → "did you mean
hsql -c?"), and the -t nord hint on hsql when an unparseable conn_str matches a
known theme name.

PR 7 — Docs. The "Headless & Agents" topic (a PR against tconbeer/harlequin-web),
README, changelog. Retire the troubleshooting/tree-sitter page, which describes a
degraded mode that no longer exists (§3.2).

PR 8 — The agent eval suite. Three tasks against fixture databases, scored on
wrong-flag retries and completion. §13 of the product plan calls this the metric that
actually matters; three tasks is enough to start and to expand against.

Ordering rationale. Everything the output contract touches ships at once, in PR 5.
The product plan's premise is that format and exit codes are a frozen API — a
half-shipped hsql -c that we "improve later" is the one thing here that can't be
undone. Internals can be improved after release; the contract can't.


5. Testing

Deterministic guards, not timing assertions. The cold-start number is the headline,
but a wall-clock assertion on a shared CI runner is a flaky test that gets deleted within
a month. So:

  • Deterministic and blocking: import-linter contracts — the hsql module graph must
    not contain textual or questionary. Plus subprocess tests asserting
    "textual" not in sys.modules after importing the headless core, and the same test
    upstream after importing textual_fastdatatable.backend. These fail identically on
    every machine.
  • Informational: the benchmark reports hsql -c "select 1" against DuckDB into the
    job summary, failing only past a loose ceiling (600ms) that catches a real regression
    without catching a busy runner.

The import contracts are load-bearing. Timing is downstream of them.

Beyond that:

  • CliRunner tests for hsql, following the existing test_cli.py patterns and its
    adapter-mocking fixtures.
  • Golden files per format (syrupy snapshots, as everywhere else in this repo)
    against a fixture result set with nulls, unicode, wide
    values, duplicate column names, and zero rows. Zero rows is the case that separates
    "the query returned nothing" from "the query failed" — A3 in the product plan — and
    the one most likely to be wrong in a format nobody exercised.
  • A type-coverage fixture — timestamp, timestamptz, date, decimal, float, boolean,
    blob, list, struct, map, and null in each — rendered into every format, as golden
    files. This is the table in §3.3 turned into a test: it's what would catch a change in
    duckdb's rendering, and it's the reason -F table and -F csv can be asserted to
    agree cell for cell.
  • text_columns() agreement: for that fixture, the strings -F table prints are the
    same strings -F csv writes.
  • stdout purity: for each format, stdout bytes identical with and without --stats;
    a query error leaves stdout completely empty.
  • Determinism: identical bytes whether stdout is a pipe, a file, or a pty; \n
    regardless of platform, which now means the temp-file copy is binary; identical bytes
    under LC_ALL=de_DE.UTF-8
    , the regression test for §3.3's locale trap.
  • -o PATH and > PATH produce identical bytes for every format — the one thing the
    temp-file path could plausibly get wrong.
  • hsql -tAc "select count(*)" returning a bare number and nothing else as its own
    named test. It's the idiom the scripting audience will try first.
  • Truncation: exactly-at-limit, one-over-limit, and under-limit, on both duckdb and
    sqlite — the two implement set_limit differently enough to matter.
  • The tricky-SQL corpus against statements.split() and, through selected_queries(),
    the editor — same fixtures, one splitter underneath. Includes non-ASCII before a
    semicolon with no following space (the §3.2 bug, which fails on main) and a
    skip-marked dollar-quoting case so the known bug is recorded rather than forgotten.

6. Decisions

Settled.

  • Limits stay on the existing machinery. cursor.set_limit() and the backend's
    max_rows, exactly as they are — no new adapter-interface method for truncation in M1
    (see §3.1).
  • No overflow detection in the TUI. It doesn't need any: --limit there is a soft
    display cap over a full fetch, so it already reports the exact total (§3.1).
    detect_overflow=False, no snapshot churn in PR 2.
  • Streaming is out. Not designed around, not partially built — see §3.3 and §7.
  • tree-sitter and tree-sitter-sql become required dependencies. One splitter, no
    fallback, the troubleshooting page retired (§3.2). Accepted cost: no install on
    platforms without a tree-sitter wheel.
  • --help is adapter-agnostic, listing adapter names and pointing at --help -a X
    for connection options (§3.6). Ted's design; better than what I proposed.
  • --result defaults to all, in every format (below).

On `--result`

The flag only matters when one invocation produces more than one result set — hsql -c "select 1; select 2", or the common -f script.sql shape of a few setup statements
followed by the query you actually care about. all emits every result set; last emits
only the final one; N picks one. With a single statement, which is most invocations,
the flag does nothing.

The product plan's proposal was all for text formats and last for data formats. The
problem isn't the values, it's that the same command would emit different things
depending on -F, which is exactly the context-dependence principle 4 exists to prevent.
Agreed on consistency, so:

--result all is the default everywhere. Formats that can't hold multiple result sets
say so rather than lying.
Concretely:

  • One result set: works in every format, no change.
  • Multiple, into table / markdown / vertical / jsonl: all of them, in order.
  • Multiple, into csv / tsv / json: error, exit 2 —
    hsql: error: 3 result sets, but csv holds one; use --result last or --result 3.

Writing two headers into one CSV is silent corruption of the same family as silent
truncation, and an error that names the fix costs an agent one retry with a clear signal.
The alternative — silently defaulting to last — throws away data the user asked for
without saying so.

Still open. Nothing.


7. Explicitly not in M1

--read-only, --timeout, --dry-run, --single-transaction (M2 — the first three
need adapter-interface additions and an ecosystem rollout). Every subcommand: catalog,
describe, fmt, spec, info, config, history, open, mcp. Streaming of any
kind — it starts at the cursor interface, not the format layer (M5; see §3.3 and §8).
Unifying the TUI's export dialog onto write_file (mechanical, but it moves a UI).
Making textual an optional extra of
textual-fastdatatable, and the separate lean hsql distribution it would enable (§4
of the product plan: near-reversible, indefinitely deferrable). Fixing dollar-quoted
statement splitting (its own issue, affects the TUI equally).

Bare hsql with no arguments prints help and never launches the TUI, from the first
release.


8. Corrections to the product plan

All of these are applied in harlequin-for-agents.md in the same PR as this
document; they're recorded here so the reasoning behind each change is written down
somewhere.

  • §4 says the full adapter option matrix on hsql --help is "unavoidable and shared."
    It's avoidable. hsql --help lists adapter names and points at --help -a X, which
    is both a token saving and the fix for the only unbounded startup cost (§3.6).
  • §12 lists cold start as a risk with "may need lazy entry-point resolution." Not a
    maybe: it's worth 160ms of a 380ms invocation with four adapters installed, and it
    grows without bound. A requirement of M1, not a contingency.
  • §12's cold-start risk should name textual-fastdatatable, not just "Textual creeping
    in."
    The 265ms the adapter interface pays today is upstream of this repo, and an
    import-linter rule here would never have caught it.
  • §5's truncation guarantee needs the limit + 1 fetch to be implementable at all.
    Worth stating in the product plan, because it's exactly the kind of thing that gets
    designed out as an implementation detail and then quietly breaks the promise.
  • §5 says "Large results stream" and lists it as an M1 concern. Nothing streams in
    M1, and it can't: HarlequinCursor.fetchall() materializes the whole result before any
    writer exists, so the format layer is downstream of the problem. Streaming is an M5
    change to the cursor interface, and M1 doesn't design around it — rows() returns an
    iterator because that's the natural shape, not as a hedge. Agreed with Ted; the product
    plan's §5 bullet should move to M5 outright.
  • §5's truncation footer says … 500 of N rows, and N is unknowable. Under a hard
    fetch limit we deliberately never learn the true total — that's the point of the limit.
    The footer is 500 of >500 -- strictly greater, which the limit+1 fetch proves and
    the conventional 500+ would understate -- and only -l 0 yields an exact count. (The TUI can say
    100,000 of 3,412,887 because its limit is a display cap over a full fetch; see §3.1.)
  • §5 lists --limit as though it means the same thing in both commands. It doesn't:
    soft display cap in the TUI, hard fetch limit in hsql. Worth one explicit sentence in
    the docs rather than letting a reader assume they match.
  • §5's --result default is format-dependent (all for text, last for data), which
    makes the same command emit different things depending on -F. It's all everywhere,
    with an error rather than silent corruption where a format can't hold more than one
    result set (§6).

Plans M2 Hsql Technical Plan (`docs/plans/m2-hsql-technical-plan.md`)

M2 Technical Plan — self-description and safety

Implementation plan for milestone M2 of Harlequin for agents,
following the M1 technical plan. The product plan says what
hsql is for; M1 built the command and the execution core under it. This one is about
letting an agent learn a database before it queries one, and about the flags that let a
human hand an agent a warehouse credential.

M2's scope, from the roadmap: catalog (one level below the match), info --json,
spec --json, config validate/show/schema/init, capability flags, the
secret option type and declarative redaction
(#667), env interpolation
(#898), --read-only, --timeout, and
a published JSON Schema. find + implements_catalog_search is promoted out of the stretch
column and ships with implementations for both in-tree adapters; optional fetch_descendants
stays future work.

Cut in review (§7 has the reasoning for each): describe, fmt, --dry-run,
--single-transaction (which M1 §7 had deferred to M2), --password-stdin, recursive
--depth, the node budget, child counts, round-trip counting, and the compact and tree
layouts.

Where M1 actually got to. PRs 1–6 shipped: the import-hygiene work, the statement
splitter and execution core, export/layout, the shared profile-merge and one-adapter
loading, hsql itself, and the two commands pointing at each other. PR 7 (the docs topic,
against tconbeer/harlequin-web) is in flight. PR 8, the agent eval suite, is not being
done — so M2 has no automated measure of "an agent used the right flag first try," and the
guards below are all deterministic ones.

Bottom line up front. M2 needs no new execution path, no new output format family, and
no second config parser: catalog listings are result sets, so --format, -o, -t/-A,
--display-rows and --stats already apply to them, and the row cap that keeps a 400-row
listing readable is the one M1 already shipped. What M2 does need is five additive members
on the adapter contract
— four fields and one method — because the features that would
otherwise be built on guesswork are unimplementable without them: the catalog knows a column
is ## but not that it is DECIMAL(18,2), no adapter can say whether it enforces read-only,
and none can answer "where does orders live" without a walk. Those are measured,
below, not feared.

On the shape of the CLI. Every capability here is a mode option on the one hsql
command — --catalog, --find, --info, --spec, --config MODE — not a subcommand
(Ted's call). It matches harlequin --config, and it removes an ambiguity subcommands
would have created: hsql takes CONN_STR positionally, so a database file named
catalog would have collided with the verb. There is no collision to rule on if there is
no verb. See §3.1.


1. Where the code actually is today

Measured on this checkout — 2.8.1 plus the unreleased M1 work — on Python 3.10.15, Linux,
with four adapters installed (duckdb, sqlite, postgres 1.3.1, mysql 1.3.0). Wall times are
the best of five runs of the real console script.

text
hsql -c "select 1"      257ms
hsql --help              90ms
hsql --version           87ms
harlequin --version     767ms

M1's targets held. Everything M2 adds has to keep them, which is most of why §3 is shaped
the way it is.

1.1 The catalog is lazy in the right way, and resolving a path is itself a walk

get_catalog() returns the top level only — one query — and every level below it is a
separate fetch_children() call on the item you are standing on. That is exactly the shape
the product plan asks for, and it is why one level below the match is cheap.

But there is no way to reach an item except by fetching its ancestors. To call
fetch_children() on mydb.analytics.orders you need that item object, which only
SchemaCatalogItem.fetch_children() can hand you, which needs the schema item, which needs
the database item. I built a DuckDB file with 400 relations in one schema and counted the
round trips (spelling the calls in the mode-option syntax of §3.1):

Call Children Round trips Local ms
hsql --catalog wide.db 1 database 1 1.9
hsql --catalog --path wide wide.db 2 schemas 2 10.2
hsql --catalog --path wide.analytics wide.db 400 relations 3 12.8
hsql --catalog --path wide.analytics.t000 wide.db 3 columns 4 22.8
the same at a hypothetical --depth 2 400 relations + 1200 columns 403 2303

So a listing costs one round trip per path segment, plus one — not the "exactly one
fetch_children()" the product plan's §6 claims. On local DuckDB that difference is 20ms;
on BigQuery or Trino it is four network calls where the plan promised one.

What recursion costs depends entirely on where you stand, which is why the product
plan's blanket --depth N is the wrong knob. Depth 2 from the root fetches every database's
schema names: one round trip per database, and databases are few. Depth 2 from a schema is
one round trip per relation, and the last row above is what that means — 403 round trips
and 2.3 seconds against a local file
, on the call an agent would most want to make.

M2 therefore ships one level, always, and no --depth at all (§3.2). The honest
recursive answer needs fetch_descendants() on the adapter contract so a database can
answer in one query (§3.8), and that is where the flag should arrive from.

1.2 Catalog depth is adapter-specific, and nothing declares it

DuckDB and Postgres are four levels (database → schema → relation → column). SQLite is
three (database → relation → column). BigQuery's own hierarchy is project → dataset →
table, which is four levels wearing different words. Nothing in harlequin.catalog says
how deep a catalog goes or what a level is called.

So --path cannot document its grammar as database.schema.table. Paths are positional
segments
whose meaning is the adapter's, and the type label on each item (db, sch,
t, v) is what tells a reader what they got. That is a small design constraint with a
large documentation consequence, and it beats inventing a level vocabulary that would be
wrong for a third of the ecosystem.

1.3 The catalog knows the short type, and throws the real one away

CatalogItem.type_label is documented as "a short (1-3 chars) label" — ## for a bigint,
s for a varchar, ts for a timestamp. It is a Data Catalog affordance: it has to fit in a
tree column next to the name.

So a listing of analytics.orders can say id ##, customer_id ##, total #.# and no more,
which tells an agent almost nothing and would send it back to select * limit 0 to find out
what it is joining on. The type an agent needs to write a join is not in the catalog
contract
— only the glyph that fits in the IDE's tree.

The data is right there and discarded. DuckDbConnection._get_columns() selects
column_name, data_type from information_schema.columns and then does
type_label=self.connection._short_column_type(column_type), dropping column_type on the
floor. SQLite's is the same shape. So this is one field on CatalogItem, not a new query
(§3.2).

Child counts are a different matter, and M2 does not add a field for them. There is
nowhere to hang a count today, and there shouldn't be: no adapter can produce one without
fetching the children, so a child_count field would either be None everywhere or be a
fetch in disguise. The count that is knowable is len(children) once they are loaded —
which is what a listing already shows in its own footer. The product plan's "report child
counts wherever the adapter can get them cheaply" turns out to have no cheap case, and it
was proposed as a mitigation for a recursion M2 is not shipping anyway.

1.4 Capability flags have to be declared, and only some of them are

Today's capability surface is three things on the adapter class: IMPLEMENTS_CANCEL,
implements_copy (derived from COPY_FORMATS, and deprecated), and provides_details.
Everything else — cancel, validate_sql, copy, transaction_mode — lives on the
connection, and the connection class is not reachable from the adapter class without
connecting to a database.

Nor can an override be detected reflectively. type(conn).validate_sql is not HarlequinConnection.validate_sql reports SQLite as validating SQL, because
HarlequinSqliteConnection.validate_sql is defined and its body is raise NotImplementedError. That is not a bug — the app catches NotImplementedError at the call
site and carries on — but it does mean a reflective probe cannot answer the question, and
that a class variable is the pattern that can.

Cost, measured, with those four adapters installed:

ms modules
entry-point names (adapter_names()) 46 126
names + dist name and version (ep.dist) 51 128
load_adapter("duckdb") 114 187
load_adapter_plugins() — all four 309 425

Two things follow. Adapter versions are free — ep.dist.version costs 5ms over reading
names, so what is installed is answerable without importing any of it. (Capability flags are
class variables, so reporting those costs the import; §3.4.) And anything that wants every
adapter's options pays 300ms and rising, which is fine for --spec, a once-per-task
lookup, and not fine for anything on the query path.

1.5 Options can be declared but not described, validated, or marked secret

AbstractOption renders three ways — to_click(), to_widgets(), to_questionary() — and
has no fourth. There is no serialization, so --spec has nothing to call, and the
debug-info screen builds a markdown table by reaching for opt.name, opt.short_decls and
getattr(opt, "default", None) — duck-typing at a distance, which is what a real
to_dict() would replace.

Adding an abstract method is not available: AbstractOption is public API and
third-party adapters subclass it. Anything added here has to be concrete, with a base
implementation that works for a subclass that has never heard of it.

Nothing declares a value sensitive. Sampling the ecosystem, harlequin-postgres 1.3.1
declares twelve options, of which password is a plain TextOption, sslkey is a
TextOption, and passfile is a PathOption. So --spec would today teach an agent that
--password exists and say nothing about why it must not be typed on a command line, where
ps and shell history can read it.

And nothing validates an adapter option that arrives from a profile. Options typed on the
command line go through click, which rejects what it doesn't know; options read out of a
config file go straight to the adapter's constructor as keyword arguments, and the adapter
contract tells adapters to tolerate supersets of what they declare. So a misspelled key is
accepted in silence, by design, all the way down:

python
>>> DuckDbAdapter(conn_str=(":memory:",), reed_only=True, dbnmae="warehouse")
constructed fine | read_only = False

That is the safety-relevant version of §1.6: a human who put reed_only = true in a profile
believes they handed an agent a read-only connection, and did not. Nobody in the stack is in
a position to notice — except a step that knows both the profile and the adapter's declared
options, which is §3.5's second pass.

1.6 Config files merge per top-level key, and it is worse than losing a profile

Ted asked me to test this rather than assert it, and the result is sharper than the first
draft claimed. _merge_config_files() is a top-level dict.update() per file, so:

toml
# ~/.harlequin.toml
default_profile = "personal"
[profiles.personal]   # + [profiles.shared]

# ./.harlequin.toml
[profiles.project]
text
merged: {'default_profile': 'personal', 'profiles': {'project': {'adapter': 'sqlite'}}}

The cwd file's profiles table replaces the home file's outright — but default_profile
survives from the home file, because it is a different top-level key. So the two do not
just disagree, they contradict, and both commands then refuse to start:

text
harlequin.exception.HarlequinConfigError: Config files set the default_profile to
personal, but do not define a profile with that name.

A project-local config file that names one profile bricks a user whose personal file sets a
default. This predates the tomllib refactor: I ran the same probe against the
pre-refactor config.py (the tomlkit read path, from 1af8cca) and got the identical
error, so the C parser did not introduce it — dict.update() has always replaced the table
rather than merging it.

Reading is tomllib (fast, and every start-up pays it, including on the pyproject.toml
that sits in any Python project's cwd) and writing is tomlkit (slow, preserves comments,
loaded only when something writes). That split is right and M2 keeps it — but note that
config_wizard reads ConfigFile.relevant_config and writes it back through update(), so
anything that transforms values on read will be written back transformed unless the read
path used for writing stays raw. That is the whole implementation risk in env interpolation
(§3.5).

Schema errors (_raise_on_bad_schema) are good prose with no machine channel and no
positions: tomllib reports a line for a parse error inside its message, and neither
parser will tell you where key profiles.prod.limitt sits in the file.

1.7 Two expensive imports the query path must never pay

hsql is contractually forbidden from importing sqlfmt, questionary and Textual: the
hsql does not reach the TUI import-linter contract lists them, and
tests/unit_tests/test_import_hygiene.py asserts they stay out of sys.modules after every
headless import. With fmt cut from this milestone (§7), sqlfmt stays forbidden outright
— there is now no headless code path that wants it.

Two others are merely expensive, and M2 adds the first consumer of each:

  • questionary, 156ms and 329 modules. hsql must never import it, because hsql must
    never prompt (§3.7). --config init is the non-interactive path, and the wizard stays
    where it is.
  • tomlkit, 49ms alone and +8ms on top of harlequin.hsql.cli. Only --config init
    writes a file, so only --config init pays it, deferred into the mode.

For scale, harlequin.hsql.cli alone imports in 107ms across 187 modules, and importing
sqlfmt on top of it would have taken that to 217ms/304 — most of a second hsql -c
invocation, for a feature §7 explains we are not shipping.

1.8 There is nothing to build `--read-only` on, and transactions are worse

No adapter declares read-only as a capability. DuckDB and SQLite each declare their own
read-only option, which core could set by name — but harlequin-postgres declares no
such option at all, so a convention-based --read-only would silently do nothing on the
adapter most likely to be pointed at production. (Postgres can enforce it; it wants set default_transaction_read_only = on at connect, which is adapter work, not core work.)

Transactions are the same shape and further gone, which is why --single-transaction is cut
(§7). HarlequinConnection.transaction_mode returns a
HarlequinTransactionMode(label, commit, rollback) or None, and the label is whatever the
adapter chose
: SQLite offers Auto/Manual on 3.12+, Postgres offers Auto/Manual, DuckDB
overrides nothing and so returns None, and a third-party adapter may name its modes anything
at all and default to whatever its driver does. Core cannot ask for "the manual one" through
that interface without first making the contract say which mode is which.

1.9 A cancelled query returns zero rows and exit code 0

This is the one that would have shipped as a bug. I ran a billion-row DuckDB aggregate
through harlequin.query on a worker thread and called connection.cancel() from the main
thread:

text
cancel() returned in 0.0ms
executed: [(has_result_set=True, error=None)]
rows: 0
elapsed: 702ms

DuckDbCursor.fetchall() catches duckdb.InterruptException and returns None; fetch()
turns None into an empty backend, correctly, because that is also what a real empty result
looks like. So a naive --timeout prints an empty result set, writes no diagnostic, and
exits 0 — reporting "your query returned nothing" for a query that was killed. That is the
silent-wrong-answer family the whole product plan exists to avoid, and no amount of care in
the adapter helps, because the adapter cannot tell an interrupt it was asked for from one it
wasn't.

Two more things I measured while establishing what a deadline can do:

  • Exiting while a worker thread is inside DuckDB aborts the process. sys.exit(4) with
    a live daemon thread in to_arrow_table() produces terminate called without an active exception and exit code 134, which is not in the documented table. os._exit(4)
    after an explicit flush exits 4 with stdout intact.
  • So a deadline is implementable, but only as: cancel → wait → flush → os._exit. And with
    an adapter that cannot cancel, there is no way to stop the work at all.

2. The obstacles, stated plainly

  1. The adapter contract can't answer four of M2's questions. What is this column's real
    type; where does a name live; can you enforce read-only; can you validate SQL. All are
    additive, with defaults (plus one more on options, for secrets), and all need an ecosystem
    rollout before they are true everywhere — which is why the release that needs none of them
    goes first.
  2. Resolution is a walk, and recursion is unbounded. One round trip per path segment,
    and 403 for a single recursive listing of a wide schema. The answer M2 takes is to ship
    one level and no recursion at all, rather than to ship a knob with a cliff behind it.
  3. hsql grows four modes without losing a 90ms --help. Two of them need imports the
    query path must never pay — every installed adapter at once, and tomlkit — and one of
    them (--info) has to work when the database is unreachable, which is precisely when a
    diagnostic is worth most.
  4. A timeout cannot trust the adapter to report itself. §1.9. hsql has to attribute
    the cancellation it caused, because the adapter can't.
  5. Secrets leak through four new mouths at once. --info, --spec, --config show and
    errors are all output surfaces M2 adds, and every one of them handles connection options.
    Redaction has to be a property of the declaration, not a rule applied four times.

Note what is not on the list. There is no new output subsystem: catalog listings are rows,
so --format, -o, -t, -A, --display-rows and --stats are already written and
already tested (§3.3). And there is no new config parser: provenance, interpolation and
schema generation are all things to do to the config we already read.


3. Target architecture

text
harlequin/
  adapter.py      MOD  + IMPLEMENTS_READ_ONLY, IMPLEMENTS_VALIDATE_SQL
  catalog.py      MOD  + CatalogItem.type_name
  navigate.py     NEW  resolving a catalog path and listing one level below it
  options.py      MOD  + secret=..., + to_dict()
  config.py       MOD  per-profile merge, provenance, ${VAR} interpolation
  config_schema.py NEW the JSON Schema for a config file, generated from what is installed
  redact.py       NEW  redaction helpers, driven by the options that declare themselves secret
  query.py        MOD  text_columns() returns string data unchanged
  hsql/
    cli.py        MOD  + the mode options, and which of them attaches adapter options
    timeout.py    NEW  a wall clock over a run, and how it exits
    modes/        NEW  catalog.py find.py info.py spec.py config.py

M1's rule stands and now covers four modes as well as the run form:

stdout is written only by harlequin.layout and harlequin.export. stderr is written
only by harlequin.hsql.diagnostics.

M2 adds a second, which is the whole milestone in a sentence and is also testable:

No mode pays for what it did not ask for. --help imports no adapter; --spec and
--config import no database driver; --info opens no connection; --catalog fetches
one level; and the query path imports neither sqlfmt, tomlkit nor questionary.

3.1 Modes are options, not subcommands

text
hsql [OPTIONS] [CONN_STR]...

  --catalog          List catalog objects one level below --path.
  --path TEXT        Where in the catalog to look. Dotted segments; default is the top.
  --find TERM        Search the catalog for TERM, where the adapter can.
  --info             Report versions, config files, adapters and capabilities. JSON.
  --spec             Dump the installed CLI surface, including adapter options. JSON.
  --config MODE      show | list-profiles | validate | schema | init

Why options rather than subcommands (Ted's call). It matches harlequin --config, so
one shape covers both commands. It keeps hsql's parse a plain click command — M1's
two-phase build stays two phases rather than growing a verb phase in front. And it removes
an ambiguity that subcommands would have introduced for free: hsql takes CONN_STR
positionally, so hsql catalog and a DuckDB file named catalog would have needed a
disambiguation rule, docs for the rule, and a test for the rule. No verb, no rule.

Modes are mutually exclusive; two of them is a usage error, exit 2, naming both. With no
mode, hsql is what M1 shipped.

--path, not -c. Ted floated reusing -c for the catalog path and offered --path
or --relation as alternatives; --path is the one to take. -c is --command and means
SQL in this CLI, in psql, and in duckdb, and the whole premise of principle 4 is that a
flag should not change meaning with context. An agent that has learned -c types
hsql --catalog -c "select 1" sooner or later, and the right outcome is an error, not a
lookup for a relation named select 1. --path also leaves -c free to mean what it
means, if a later milestone wants --catalog and a query in one invocation.

Nor a value on --catalog itself (Ted's question, taken after PR 9). --catalog PATH
reads better and costs two tokens fewer, but click takes an optional value greedily -- the
next token, whether or not an = is written -- and CONN_STR is a variadic positional. So
hsql --catalog mydb.db would read the file as a catalog path against an in-memory
database, which is the hsql catalog-versus-a-file-named-catalog ambiguity in a different
spelling, and it would need the rule §3.1 exists to avoid. With --path, CONN_STR stays
free to sit anywhere on the line and hsql --catalog mydb.db lists what is in mydb.db.
--find composes either way: --path is the scope for it too (§3.8).

Which mode needs what, because this is the table that keeps §3's second rule true:

mode adapter import connection notable import
(none — run SQL) one yes —
--catalog, --find one yes —
--info all, for capability flags; one under -a no —
--spec all, or one under -a no —
--config show/validate/schema all, for adapter options no —
--config init one no tomlkit

--info not connecting is a design decision, not an omission: the diagnostic an agent runs
when the database is unreachable must not itself require the database. Capability flags come
from declarations (§3.4) precisely so that this holds — reading a declaration costs an import
of the adapter class, but never a connection.

--info and --spec emit JSON documents rather than rows, so --format does not apply to
them; a caller who sets it explicitly gets the same kind of stderr note --display-rows
already produces when it cannot reach the chosen format. --catalog and --find produce
rows, and every format applies (§3.3).

The modes live one module apiece under harlequin/hsql/modes/, imported by the callback
when that mode is chosen — which is how --config init's tomlkit and --spec's
all-adapters import stay off every other invocation.

3.2 `harlequin.navigate` — one level, and what it cost

python
@dataclass(frozen=True)
class CatalogPath:
    segments: tuple[str, ...]  # positional; the adapter names the levels
    glob: str | None = None  # a trailing wildcard only


@dataclass
class Listing:
    parent: CatalogItem | None  # None at the top level
    items: list[CatalogItem]


def resolve(connection, path) -> CatalogItem | None: ...
def list_children(connection, path) -> Listing: ...

Three things this module owns, and nothing else does:

One level, always. No --depth. §1.1 is the argument: recursion's cost depends on the
level it starts from, so a single number cannot be safe at every level, and the one place an
agent most wants it is the one place it is 403 round trips. An agent that wants a schema's
columns asks for one relation at a time, which is what the IDE's own catalog does and what
Ted proposed in review. Recursion comes back when an adapter can answer it in one query
(§3.8), and it arrives with the capability rather than ahead of it.

A trailing glob is sugar; an interior one is a different mode. --path analytics.ord*
filters one resolved parent's children in the client and stays one round trip.
--path *.orders cannot be evaluated without fetching every candidate level, so it belongs
to --find (§3.8) and is refused here rather than quietly walked.

Quoting is this syntax's own, not a database's. A segment holding a dot, a quote or a
wildcard is written in double quotes on every adapter -- not MySQL's backticks or SQL
Server's brackets, since accepting those would make a label that contains one unreachable,
and a path an agent copies out of a listing would then depend on which adapter wrote it. A
path is spell()'s output, and the path column of a listing is where a caller gets one.

There is nothing to instrument, either. An earlier draft had --stats report round_trips,
which existed to make a recursive walk's cost visible after the fact; without recursion the
number is len(path) + 1 and a caller can read it off the path they typed.

There is no node budget, because there is nothing left to bound: one level is one round
trip, and a listing with four hundred rows in it is a display problem that M1 already
solved. --display-rows caps what a text layout prints and the footer says (40 of 400 rows), exactly as it does for a query.

The one field this needs on the contract:

python
@dataclass
class CatalogItem:
    ...
    type_name: str | None = None  # the adapter's own full type, e.g. DECIMAL(18,2)

It defaults to None and is populated in-tree from data the adapters already fetch (§1.3). A
listing prints it beside type_label rather than instead of it, so an adapter that never
adopts the field degrades to today's short label rather than to a blank column.

One caveat for adapter authors: CatalogItem is a dataclass and its subclasses append
fields, so a new base field shifts positional argument order for subclass constructors.
Every in-tree call site uses keywords (from_parent(...)), and the adapter template does
too, but the changelog entry should say so.

3.3 Catalog listings are result sets, so the output layer is already written

A listing is rows: path, name, type, type_name, query_name. Making it a
ResultSet — create_backend() takes a sequence of rows and the column names, with no
database involved — means --catalog inherits --format csv|json|jsonl|markdown|table| vertical|parquet, -o PATH, -t/-A/--no-header, --display-rows and the byte-for-byte
determinism M1's snapshots already pin. The alternative is a second renderer for the same
job, which is how --format json and a catalog's JSON end up disagreeing about how to spell
null.

And no new layouts. An earlier draft added compact and tree; both are out.

tree had nothing to draw: one level is a flat list of children, not a tree. And compact
— analytics.orders(id BIGINT, customer_id BIGINT, total DECIMAL(18,2)) — only reads that
way for a relation and its columns. §1.2 is the reason it can't generalize: what a level
is belongs to the adapter, so parent(child TYPE, …) would render a database's schemas
as mydb(analytics sch, main sch), which is a format pretending to a structure the catalog
does not promise. The token efficiency it was after is already available over the same rows
— hsql --catalog --path analytics.orders -tA --format csv is names and types and nothing
else — and it stays honest at every level, because a row is just a row.

One change in query.py makes this cheap: text_columns() returns string data
unchanged.
Today every text layout casts through duckdb, which is right for query results
(§3.3 of the M1 plan) and pointless for a listing that is already strings — and it would
import duckdb (~85ms) on a hsql -a postgres --catalog that has no other reason to. The
short-circuit is one type check, and it is also a small win for select results that are
all VARCHAR.

query_name, always. Adapters already compute the correctly quoted identifier for every
item; emitting it means the agent never guesses whether this backend wants "Orders" or
`orders`. It costs nothing and no other client does it.

Describing a relation is listing it. --catalog --path mydb.analytics.orders is the
columns of orders, with their names, types and quoted identifiers — which is everything a
describe was going to print. There is no second mode for it (§7).

3.4 Capabilities are declared, not detected

python
class HarlequinAdapter(ABC):
    IMPLEMENTS_CANCEL = False  # exists
    IMPLEMENTS_READ_ONLY = False  # new: connect() honors read_only=True
    IMPLEMENTS_VALIDATE_SQL = False  # new: the connection's validate_sql() is real
    IMPLEMENTS_CATALOG_SEARCH = False  # new: search_catalog() (§3.8)

Declarations rather than reflection, because reflection cannot answer the question: SQLite
defines validate_sql and raises NotImplementedError from it (§1.4). The app already
handles that at the call site, so nothing is broken today — but an adapter that says what
it does is the better pattern, and it is the only one --info can read without connecting.

Two consequences worth stating:

  • A flag whose capability is undeclared refuses, before connecting. hsql --read-only -a postgres exits 2 with postgres does not declare read-only support; see hsql --info —
    rather than connecting, running the query, and hoping. Refusing early is the entire point
    of the flag: --read-only exists so a human can hand over a credential, and a flag that
    no-ops is worse than one that is absent.
  • Core still catches NotImplementedError at the call site, as the app does. A
    declaration can be wrong, and the failure should be a clear error rather than a traceback.

IMPLEMENTS_VALIDATE_SQL has no consumer in hsql now that --dry-run is cut (§7). It is
still worth declaring: --info reports it, and the app can consult it instead of calling a
method it expects to raise.

hsql --info reports, per installed adapter: name, distribution, version, declared
capabilities, and whether the adapter imported at all. Plus Harlequin's version, Python's,
the platform, the discovered config files in precedence order, and the active profile
with every secret redacted (§3.7).

It opens no connection, but it does import: capabilities are class variables, so reading
them means loading the adapter class. That is the one cost this mode pays — 309ms for four
installed adapters, against 51ms for names and versions alone (§1.4) — and it is the right
trade for a once-per-task lookup whose entire job is that the agent stops guessing.
hsql --info -a duckdb imports one (114ms) for the common case of asking about the adapter
you are using, and an adapter that fails to import is reported with its capabilities as
"unknown" and the import error alongside — never as false, because guessing false about
implements_read_only is the direction that gets someone hurt.

3.5 Config: validate each file, then merge, with provenance

Validation moves in front of the merge (Ted's call). Today load_config() merges every
discovered file into one dict and then calls _raise_on_bad_schema() on the result, which is
backwards twice over: the error can only name a key, never the file it came from, and the
thing being validated is a structure no user wrote. Validating each file as it is read means
every message can say which file and which key, and the merge then only ever combines
inputs already known to be well-formed.

python
def load_config(config_path: Path | None) -> Config:
    return merge([validate(ConfigFile(p)) for p in _find_config_files(config_path)])

That reordering is free, and it is most of what makes --config validate worth having.

The merge becomes per profile, and files are read nearest first (§1.6; amended in PR 1,
Ted's call). profiles merges per profile name and keymaps per keymap name, and the
nearest file that defines one supplies it whole. An earlier draft of this section had a
profile merge per option as well; that is what PR 1 traded away, because it is
incompatible with the early stop below — a profile's keys are not known to be complete until
every file has been read, so per-option merging means no file can ever be skipped. Half a
connection from each of two files is also not a connection either of them describes.

This is a behavior change for both commands and a bug fix for a failure mode that is worse
than losing a profile: before it, a project-local file that names one profile, plus a home
file that sets default_profile, makes both commands refuse to start. It gets a Bug Fixes
changelog entry and a test that pins the new semantics against both files.

Discovery is reversed, and the profile read path stops early (Ted's call, PR 1).
_find_config_files() returns candidates highest priority first, so the first definition of
anything is the winning one, and load_profile() — the read path hsql uses when it
want a profile and not the keymaps beside it — returns at the file that defines it. The
files behind that one are never opened, parsed or validated. -P None reads nothing at all.
load_config() is the whole document, for --config show and the IDE's keymaps, and has no
reason to stop.

Resolving a name is separated from reading the document in the same PR, because otherwise the
two commands disagree about a config file neither of them is using: a default_profile that
names no profile is raised where the name is used, so -P other and -P None start rather
than being refused over a key they overrode. What remains is the honest half of the
difference — hsql cannot report a problem in a file it stopped before opening — and
--config validate (PR 3) is the mode that reads everything and reports everything. Measured with four candidate files present, the skipped reads pay for most
of msgspec's import: with four candidate files present, hsql -c "select 1" and hsql --help
are both within noise of the pre-PR-1 command (±3ms), and so is a run that finds exactly one
config file.

Validation itself runs in two passes, and the second one is new ground (Ted's design).
Pass 1 validates what core owns, per file, before the merge: the top-level keys, the shape
of profiles and keymaps, and the profile keys with meaning to harlequin or hsql. It
cannot reject what it does not recognize, because every adapter's options live in the same
table.

Pass 2 is what makes that safe. Once the adapter is known — which the first pass already
settles, and load_adapter() has already imported for the run about to happen — its
ADAPTER_OPTIONS say exactly which further keys are legal, so the selected profile can be
validated in full:

python
declared = {sluggify(o.name) for o in adapter_cls.ADAPTER_OPTIONS}
allowed = core_keys | TUI_ONLY_KEYS | declared

This validates a surface nothing validates today (§1.5): reed_only = true in a profile
is silently discarded by the adapter's **kwargs, and the user believes they are connected
read-only. Pass 2 is the only place in the stack that knows both the profile and the
adapter's declared options, so it is the only place that can say unknown option 'reed_only' for adapter duckdb; did you mean 'read_only'?

Two things bound how strict it can be:

  • "Valid" is a union of three sets — core keys, TUI_ONLY_KEYS (a profile written for
    the IDE has to work headlessly), and the adapter's declared options. Get the union wrong
    and configs that work today start failing, so each set gets a test.
  • Values may legitimately arrive uncast. adapter.py tells adapters to "check the types
    of options, as they may not be cast to the correct types", and TOML gives us whatever the
    user typed — port = 5432 and port = "5432" both work today and must keep working. So
    pass 2 checks names and declared choices, which is where the value is, and stays
    permissive about scalar types.

Which mode validates what follows from the passes: the run path validates the one selected
profile, with the one adapter it was going to import anyway, while --config validate
validates every profile and imports each adapter its profiles name — 309ms for four (§1.4),
which is the right trade for the mode whose whole job is to check.

On declaring the schema as a model rather than hand-rolling it. Pydantic, msgspec and
cattrs were all measured against a Harlequin-shaped config; the numbers and the facts that
decide it are in §6.1. The short version: Pydantic costs +135ms on every invocation and so
fails on the axis it was proposed to improve
, cattrs cannot generate the JSON Schema this
section needs, and msgspec is the one that pays for itself at +37ms — strict where it
matters, and msgspec.defstruct() builds pass 2's per-adapter struct in 0.022ms, which is
what makes the two-pass design declarative rather than another hand-written loop. Ted is
leaning toward msgspec; §6.1 records what would reverse that, and PR 1 is where it becomes
real.

Provenance falls out of doing the merge properly:

python
@dataclass(frozen=True)
class Provenance:
    value: Any
    path: Path  # which file this key's winning value came from
    overrode: list[Path]

--config show prints the effective config with a # from ~/.harlequin.toml per key, and
--config show --json emits the same as {key: {value, from, overridden_by}}. That is the
single best troubleshooting artifact in the plan, and it is also how a human discovers which
file is winning.

--config list-profiles (Ted's call) prints the names of every profile found, which is
the question a human actually asks first — what can I pass to -P — and the one show
answers only by making them read a merged document. It is rows, so -tA gives a bare list a
shell loop can consume, and it marks which profile is the default. It is also the mode that
would have made #1040 obvious: a profile that a project-local file quietly displaced is a
name missing from a short list.

${VAR} and ${VAR:-default} (Ted's call), resolved on read, over every string value
in a profile, recursively through arrays and tables:

  • A bare { never triggers anything, which is Ted's constraint on #898: nothing anyone has
    in a password today needs escaping. $${ is the escape for a literal ${.
  • An unset variable with no default is a HarlequinConfigError naming the variable and the
    file it appears in
    — exit 2, never an empty string. A password that silently becomes ""
    is an authentication error three layers away from its cause.
  • Resolution happens on the read path only. ConfigFile.relevant_config stays raw,
    because config_wizard reads it and writes it back (§1.6): interpolating there would bake
    a resolved secret into the user's file on their next harlequin --config. This is the one
    place where getting the layering wrong writes a plaintext password to disk, so it gets its
    own test.
  • Amended in PR 16: the read path is where a profile is selected, not where a file is
    read.
    Resolving each file as it was read would have refused an invocation over a variable
    named in a profile it was not running — and the IDE reads every discovered file for its
    keymaps, so one ${MYPASSWORD} in a home config would have stopped harlequin from
    starting under any profile. Resolving in _select_profile() instead is the rule
    default_profile already follows (a name is raised where it is used), and it costs the
    merge's Provenance — once every file is merged, that is the only thing that still knows
    which file a profile was written in, which an unset variable's message has to name. Two
    consequences worth writing down: --config show and the IDE's debug
    screen report what the files say, so they print ${MYPASSWORD} rather than resolving it —
    which is also the reading that keeps an unset variable from breaking a report about the
    file naming it — and default_profile and the keymaps tables are not interpolated at all,
    which nothing needs and which is what buys the laziness. --config validate resolves every
    profile in every file, and reports an unset variable like any other problem.
  • Shell-command interpolation (asked for on #898) is out: it is arbitrary code execution
    driven by a file discovered from the working directory.

--config validate reports {file, key_path, message, line?} per problem — one entry
per file per problem, which is what validating before merging buys. Line numbers only where
the parser gives one: tomllib puts a line in its parse-error message, and neither parser
reports positions for a key we merely dislike. Saying "line unknown" is better than a made-up
number and better than not shipping the mode.

--config schema generates a JSON Schema for .harlequin.toml from the config model
plus the installed adapters' options — which is why AbstractOption.to_dict() (§1.5, §3.7)
has to land first — so it is locally accurate: it knows this machine has
harlequin-databricks and what its options are. With a declared model this is
msgspec.json.schema() plus the adapter options grafted on; hand-rolled, it is a small
generator over the same two sources. A static base schema also ships in the
package, at harlequin/schemas/config-v1.json, for the site to publish at
https://harlequin.sh/schemas/config/v1.json — that publication is a harlequin-web PR,
outside this repo.

--config init writes a profile through the existing ConfigFile tomlkit path, so
comments and key order survive, taking --profile NAME, --adapter, and the adapter's own
options as flags. It imports no questionary — the wizard stays exactly where it is, and each
command gets the affordance right for its audience. It is also the exact counterpart of
harlequin --config, which is the argument for spelling the modes as options in the first
place.

3.6 Safety: read-only and timeout

--read-only is a read_only argument on HarlequinAdapter.__init__, passed by both
commands, and refused before connecting when IMPLEMENTS_READ_ONLY is false (§3.4). An
option of that name in the bag handed to the adapter would have worked in-tree, but only
because DuckDB and SQLite each declare an option called read-only — a convention rather
than a contract, and the same convention §1.8 rules out for the adapters that declare
nothing (amended in PR 13, Ted's call). So the two in-tree adapters drop their own
read-only options and take the argument, and --read-only is a core flag on harlequin
as well as hsql. Out-of-tree adapters are a long tail, and hsql --info is what makes the
tail legible.

--timeout SECONDS is a wall clock over the whole run — execute and fetch, because
DuckDB executes lazily and the work happens in fetchall() (§1.9):

  1. Refuse at parse time if IMPLEMENTS_CANCEL is false. There is no way to stop the work
    otherwise, and a timeout that can only lie about having stopped it is worse than no flag.
  2. Run the statements on a worker thread; the main thread waits on the deadline.
  3. On expiry: connection.cancel(), then join for a short grace period.
  4. hsql attributes the cancellation itself. The result set that comes back after a
    cancel is empty and error-free (§1.9), so it is discarded rather than printed, the
    diagnostic is hsql: error: timed out after 30s, and the exit code is 4 — the number
    ExitCode.TIMEOUT has been reserving since M1.
  5. If the grace period expires too, flush both streams and os._exit(ExitCode.TIMEOUT).
    Plain sys.exit with a live worker inside DuckDB aborts the interpreter and exits 134
    (§1.9), which would be a documented-exit-code violation caused by the flag that exists to
    make failure legible.

SIGINT takes the same path and exits 130, which it already does.

It bounds --catalog and --catalog-search as well as a run (amended in PR 14). Both
connect and both wait on the database, and this repo's own rule about a flag that silently
does nothing (report_limit_ignored, report_row_cap_ignored) applies hardest to the one
flag whose job is safety. It is the same wrapper around the mode's report call; the modes that
report on files or on the installation connect to nothing and are left alone, as --read-only
leaves them.

hsql's --timeout collides with the SQLite adapter's own timeout, and the adapter's is
renamed
(amended in PR 14). §3.4's rule says an adapter option that collides with one of
hsql's flags loses the spelling -- which is right for the out-of-tree tail and wrong to leave
in place for an adapter we ship: sqlite's option is how long to wait for a locked table, and
losing it would cost a real setting and silently reread a profile's timeout as a deadline. So
it becomes lock-timeout, which is what it does, exactly as PR 13 renamed connection_mode to
mode. Breaking, and one line of the changelog.

--single-transaction is cut (§7): transaction modes are named by each adapter and default
to undefined behavior, so core cannot drive them generically, and a caller who wants one
transaction can write begin and commit in the script they are already sending.

3.7 `harlequin.redact` — the helpers, and what tells them what to hide

python
def redact_profile(profile: Profile, options: Sequence[AbstractOption]) -> Profile: ...
def redact_conn_str(conn_str: Sequence[str]) -> list[str]: ...
def redact_text(text: str, secrets: Container[str]) -> str: ...

Redaction helpers, and nothing else — what to hide is not a list kept here. It is a
declaration on the option itself: AbstractOption gains secret: bool = False, as a class
attribute and a keyword, so a third-party subclass that has never passed it still answers
False rather than raising. That is the whole point of doing it declaratively — core cannot
enumerate every adapter's secret (--service-account-key, --token, --tls-key, whatever
the next adapter invents), but each adapter can declare its own once and every consumer gets
it free: to_dict() reports it, which is what teaches an agent not to construct
hsql --password hunter2; to_questionary() masks the input; and --info, --spec,
--config show and error rendering all route through redact_profile.

Two things the declaration cannot cover, both with a helper of their own above:

  • Connection strings are positional, so no option type describes
    postgres://user:pw@host/db. redact_conn_str is DSN-aware — and
    #354 is evidence people really do put
    passwords there.
  • A driver exception that echoes a DSN does not pass through our option layer, so
    redact_text is the backstop applied to error messages before they are printed.

hsql never prompts, and adds no new channel for a password either. A prompt that blocks
on stdin is the worst failure mode for an agent — no output, no exit code, the whole turn
burned until something times out — so a secret an invocation needs and cannot find is a
fast failure naming the profile or the driver's own environment variable. An earlier draft
added --password-stdin; it is out (§7). Between profiles, ${VAR} interpolation (§3.5) and
the variables the drivers already read (PGPASSWORD, a pgpass file, and each adapter's
equivalent), there is no gap it would fill that is worth a flag whose whole job is to consume
the stream -f - also wants.

What this milestone contributes to #667 is the declaration and the redaction that follow from
it. The interactive prompt belongs to harlequin, and is not here.

3.8 `--find`, and the capability recursion is waiting for

--find is what an agent actually needs — where does orders live, which tables have a
customer_id
— and it cannot be a walk. It wants an optional capability:

python
class HarlequinConnection:
    def search_catalog(
        self, term: str, kind: Literal["tables", "columns", "all"]
    ) -> list[CatalogItem]: ...  # raises NotImplementedError

One information_schema-style query where the adapter can serve it, an explicit "not
supported by this adapter, try --catalog" where it can't, surfaced through
IMPLEMENTS_CATALOG_SEARCH in --info.

fetch_descendants(depth) on InteractiveCatalogItem, defaulting to a fetch_children
walk, is the same bet at the other end, and it is the precondition for recursion ever
coming back
(§3.2): it is what turns §1.1's 403 round trips into one for an adapter that
can express the whole level in a single query, and it improves the IDE's expand-all path
too. A --depth flag on top of a walking default would just be the cliff again with a
prettier name, which is why it waits for this rather than shipping beside it.

On the other idea from review — concurrency. Opening several connections to parallelize a
recursive walk would help wall-clock time and nothing else, and it costs more than it looks:
the adapter contract says nothing about a connection being usable from two threads, nor does
it offer a way to open a second one from an existing connection, so hsql would have to
re-connect() (re-authenticating N times) and hope. It also multiplies load on the database
for a call the agent should not be making in bulk. Worth revisiting if fetch_descendants
lands and is still too slow; not worth it as the first answer.


4. Sequencing

Three releases, config and self-description first (Ted's call). That group fixes bugs
that exist today — the merge in #1040, and error messages that can't name the file they came
from — and needs no adapter change at all, so it can ship while the catalog's contract
addition is still being rolled out. The catalog follows, with search implemented for both
in-tree adapters rather than left as a stretch. Safety is last, because it is the only group
whose flags are false on most adapters until the ecosystem catches up.

PRs 15 and 16 were pulled forward into Release A (Ted's call, taken after PR 7). They are
the two that never needed the rest of Release C: neither declares a capability, so neither
waits on the ecosystem. They belong with the self-description modes instead — the whole of
what --config show, --info and --spec do is print a user's own config back, and the
release that adds three ways to print a profile should not be the release before the one that
learns which of its values must not be printed. ${VAR} follows redaction because it is the
other half of the same answer: redaction keeps a secret out of Harlequin's output, and
interpolation keeps it out of the config file to begin with. They keep their numbers, which
the rest of this document refers to; only the release they ship in changed.

Numbering assumes M1's remaining work releases as 2.9.

Release A — config and self-description (2.10)

PR 1 — Validate per file, then merge per profile. The reordering, the per-profile merge, the
second pass over the selected profile's adapter options, and the errors that can finally name
a file. This is where msgspec arrives as a dependency, or doesn't. Fixes
#1040, which is a hard stop for anyone
whose home config sets default_profile and who then adds a project-local file. Behavior
change, Bug Fixes entry, and the failing case as its test.

PR 2 — --config show and --config list-profiles. Provenance per key, and the short
list of names that answers "what can I pass to -P".

PR 3 — --config validate. The machine channel over PR 1's per-file diagnostics.

PR 4 — AbstractOption.to_dict() and --spec. Concrete, never abstract, with a base
implementation that works for a subclass that predates it. --spec covers hsql's own options
and every installed adapter's; -a NAME narrows it to one. (It cannot cover harlequin's
own flags: hsql may not import harlequin.cli, which builds the IDE's command. Say so in
the output rather than pretending the list is exhaustive.)

PR 5 — --info. Versions, platform, discovered config files in precedence order, active
profile, and declared capabilities per adapter. No connection.

PR 6 — --config schema and the packaged base schema. Depends on PR 4's serialization.

PR 7 — --config init. tomlkit deferred; no questionary.

PR 15 — Secret options and redaction. secret= on options, harlequin.redact, DSN
redaction, masked wizard input. Closes #667's structural half. Pulled forward from Release C:
every mode Release A adds prints a profile, and this is what decides what they print.

PR 16 — ${VAR} interpolation. Closes #898. Pulled forward with PR 15 — a caller told to
keep a token out of their config file needs the spelling that replaces it in the same release.

PR 8 — Docs. The config spec page, the self-description pages, the site's JSON Schema
route, and — arriving with PRs 15 and 16 rather than in PR 17 — the secrets guidance and the
${VAR} spelling.

If the model question in §3.5 resolves toward msgspec, it lands in PR 1 — the declaration
is what PR 1's validation and PR 6's schema both read, and retrofitting it later means
writing both twice.

Release B — the catalog (2.11)

PR 9 — Mode options, and --catalog at one level. The mode flags and their mutual
exclusion, --path, and which modes attach adapter options. harlequin.navigate with path
resolution and single-level listing. Listings as result sets, query_name included, and the
text_columns() string short-circuit that keeps duckdb out of a Postgres catalog call.
Guard: hsql --help still imports no adapter, and is still ~90ms.

PR 10 — Real type names. CatalogItem.type_name on the contract, populated by the DuckDB
and SQLite adapters from data they already fetch. This is the PR that makes a listing say
total DECIMAL(18,2) instead of total #.#.

PR 11 — --find. IMPLEMENTS_CATALOG_SEARCH and search_catalog() on the connection,
implemented for DuckDB and SQLite rather than declared and left to the ecosystem;
explicit refusal on adapters that don't declare it.

PR 12 — Docs. Catalog and Introspection in the "Headless & Agents" topic (a
harlequin-web PR), plus the adapter-author note for type_name and search_catalog().

Release C — safety (2.12)

PR 13 — Capability declarations and --read-only. IMPLEMENTS_READ_ONLY and
IMPLEMENTS_VALIDATE_SQL on the contract, declared by both in-tree adapters, reported by
--info, and refused where undeclared. Companion issues filed against the out-of-tree
adapters we maintain.

PR 14 — --timeout. The deadline, the cancel, the grace period, the os._exit, and the
attribution that keeps a cancelled query from printing as an empty one.

PRs 15 and 16 shipped in Release A — see there.

PR 17 — Docs. Safety page and the psql differences table. The secrets guidance went with
PR 8, alongside the release that shipped it.

Ordering rationale. Within each release the contract change lands with the first consumer
that needs it, as in M1 — a field nothing reads is a field nobody notices is wrong. Across
releases: fix what is broken before adding what is new, and let nothing that needs an
ecosystem rollout block anything that doesn't.


5. Testing

The M1 guards stay and grow; the new ones are the same kind — deterministic, and failing
identically on every machine.

  • Import hygiene, extended per mode. hsql -c "select 1" imports no tomlkit, no
    questionary and (still) no sqlfmt; hsql --help imports no adapter; hsql -a sqlite --catalog imports no duckdb. Each is a subprocess reading sys.modules, and each
    deferral has a matching ignore_imports entry with a comment at the import site.
  • A fake adapter that declares nothing proves every refusal path: --read-only,
    --timeout and --find each exit 2 with a message naming the adapter, before any
    connection is opened.
  • Golden files for --catalog, --info, --spec and --config show in
    every format they support, as syrupy snapshots alongside the M1 output snapshots. Fixtures
    with unicode labels, a zero-child node, and an adapter that populates no type_name — the
    degradation path is the one nobody exercises.
  • Redaction is asserted negatively and exhaustively: for a profile with a secret in an
    option and in a connection string, the secret's literal value appears in no byte of
    --info, --spec, --config show, any error message, or --stats.
  • Interpolation: ${VAR}, ${VAR:-default}, $${ as a literal, an unset variable
    exiting 2 and naming both the variable and the file, and — after PR 16's amendment above —
    a profile the invocation is not running being left alone. The one that matters most is
    that harlequin --config on a file containing ${MYPASSWORD} writes ${MYPASSWORD} back.
  • The merge fix gets the failing case as its test: a home file with default_profile and
    two profiles, a cwd file with a third, asserting all three survive and the default still
    resolves. That combination raises HarlequinConfigError on main today.
  • Timeout tests use a fake adapter, not a slow query: execute blocks on an event,
    cancel sets it. Deterministic, fast, and it exercises the attribution logic, which is the
    part that would otherwise be tested by accident. One integration test against DuckDB covers
    the real interrupt path, marked slow.
  • A listing is as deterministic as a result set: identical bytes whether stdout is a
    pipe, a file or a pty, \n on every platform, and unaffected by LC_ALL — the M1
    properties, now over rows that never went through a database.
  • Pass 2's allowed set is asserted per source: a core key, a TUI_ONLY_KEYS key and a
    declared adapter option are each accepted in a profile, and a misspelling of one is
    rejected naming the adapter — with port = 5432 and port = "5432" both still accepted,
    since the adapter contract promises values may arrive uncast.
  • The merge fix is asserted per profile, not just per table: a profile defined in two
    files is the nearer file's, whole, and the profiles only the farther file defines survive
    beside it — which is the half of "later wins" that a table-level replace gets wrong.
  • Early stopping is asserted with an unreadable file: a home file containing invalid
    TOML, and a cwd file defining the wanted profile. A file that is opened at all takes the
    test down with it, which is a guard that cannot pass by accident.

Not tested, because it was dropped from M1: an agent eval suite. §13 of the product plan
calls it the metric that actually matters, and M2 widens the surface an agent has to choose
from. If it comes back, --spec is what makes it cheap to write, and Release B is where it
would land.


6. Decisions

Settled.

  • Modes are options, not subcommands (Ted's call, §3.1) — consistent with
    harlequin --config, and no verb-versus-conn_str ambiguity to adjudicate.

  • The catalog path is --path, not a reused -c and not a value on --catalog itself
    (§3.1). -c would mean SQL in one invocation and an identifier in another; --catalog PATH would take its value greedily and read hsql --catalog mydb.db as a path against an
    in-memory database.

  • One level, no --depth, no node budget (§3.2). Recursion returns with
    fetch_descendants(), which is what would make it one round trip instead of 403.

  • No child_count field (§1.3). A count is only knowable by fetching, and len(children)
    after a listing is the honest version of it.

  • Three releases, config and self-description first (Ted's call, §4) — they fix existing
    bugs and need no adapter change; then the catalog, with search implemented for both
    in-tree adapters; then safety.

  • Secrets and ${VAR} ship in Release A, not Release C (Ted's call, §4, taken after PR 7).
    They declare no capability, so nothing in them waits on the ecosystem, and the release that
    adds three modes for printing a profile is the one that has to know what not to print.

  • No --describe (Ted's call, §7). --catalog --path db.schema.relation is a describe.

  • No new layouts (Ted's call, §3.3). tree has no tree to draw over one level, and
    compact only reads as parent(child TYPE, …) for a relation and its columns, which §1.2
    says is not a shape the catalog contract promises.

  • No round-trip counting (Ted's call, §3.2). Without recursion the number is len(path) + 1, which a caller can read off the path they typed.

  • Validation runs in two passes (Ted's design, §3.5): per file ahead of the merge for the
    keys core owns, so an error can name the file it came from, and then the selected profile
    against the adapter's declared options — which catches a misspelled adapter option that
    today is discarded in silence (§1.5).

  • --config list-profiles (Ted's call, §3.5).

  • --read-only is a declared capability and refuses when it is absent (Ted's call, §3.4).
    The flag works on two of fifteen adapters on the day it ships, and says so on the other
    thirteen instead of quietly not working.

  • No --single-transaction, no --password-stdin (Ted's call, §7).

  • Secrets: declare and redact (Ted's call, §3.7). No prompt in hsql, ever, and no new
    input channel; the IDE's interactive prompt is not in this milestone.

  • ${VAR} and ${VAR:-default} (Ted's call, §3.5), resolved on the read path only, with
    an unset variable an error rather than an empty string.

  • Catalog listings are result sets, so M2 adds no output subsystem at all (§3.3).

  • Capabilities are declared, not detected — reflection cannot tell a real implementation
    from a raising stub (§1.4).

  • --info does not connect, and --spec and --config do not either (§3.1).

  • No fmt, no --dry-run (Ted's call, §7).

  • No shell-command interpolation in config values (§3.5).

  • Config files are read nearest first, and the profile read path stops at the file that
    defines what it was asked for
    (Ted's call, §3.5). The cost of that is per-option merging
    within a profile, which PR 1 gave up to get it: a profile is the nearest file's, whole.

  • The config is declared as msgspec models (settled in PR 1, §3.5 and §6.1). Config is
    the TypedDict and the model, so there is one declaration of what a config file may say
    and msgspec.convert(raw, Config) is the whole of pass 1; pass 2 builds a struct from the
    adapter's own ADAPTER_OPTIONS and parses the profile into it, so an option arrives as the
    type its adapter declared. Config is a Struct with forbid_unknown_fields, so msgspec
    refuses a key nobody declared and the import is module-scope: ~13ms on an invocation that
    finds no config file at all, and within noise of main on one that finds any, because the
    files early stopping skips pay for it. One thing is still written by hand, because the
    message is the point: the near miss on a key -- read-only, reed_only, keymap_names --
    which is where difflib says did you mean read_only, computed only once a conversion
    has already failed.

Still open.

  • Nothing from PR 1. §6.1's reversal conditions — the import cost mattering more once
    --info and --spec are real, or wanting Pydantic's extra="allow" — are still the
    things that would reopen it.

6.1 The config model, measured

Three libraries were suggested or considered for declaring Config/Profile rather than
hand-rolling _raise_on_bad_schema(). All of them were run against a Harlequin-shaped
config on this checkout, because the deciding facts are not the ones the READMEs lead with.

Pydantic 2.13 msgspec 0.21 cattrs 26.1 hand-rolled
cost on every invocation +135ms +37ms +63ms 0
new runtime dependencies pydantic, pydantic-core msgspec (C ext) attrs, cattrs, exceptiongroup (3.10) none
unknown keys — i.e. adapter options preserved (extra="allow") dropped dropped n/a
limit = true coerced to 1 rejected coerced to 1 rejected today
limit = "500" coerced to 500 rejected coerced to 500 rejected today
error carries a path yes in the message via transform_error() ours to write
generates JSON Schema yes yes no ours to write

Four things decide it, and none is the headline benchmark:

  • The cost is model construction, not import, and config is read on every invocation of
    both commands.
    Pydantic builds a core schema per model at class-definition time, so its
    135ms lands on hsql -c "select 1" — 257ms today — taking it to roughly 390ms, past the
    300ms the product plan tracks in CI. There is no lazy way out, because the read path is
    the validating path. Pydantic is a no on the one axis it was proposed to improve.
  • Adapter options are unknown keys, and two of the three eat them. A Profile model with
    declared fields turns {"adapter": "postgres", "dbname": "warehouse"} into a profile with
    no dbname, which is the value the adapter needed. msgspec and cattrs both drop unknown
    keys (their only alternative is to reject them), so under either, profiles stays
    dict[str, dict[str, Any]] with a model validating the keys core owns and the raw dict
    carried alongside. Only Pydantic's extra="allow" preserves them — the one place it is
    genuinely the better tool.
  • Two of the three would reintroduce a bug we already guard. limit = true structures to
    1 under Pydantic and cattrs. parse_row_count() rejects it today, with a comment saying
    why: a bool is an int in Python, and true is not one row. msgspec is strict and rejects
    it, which is the behavior we want and currently hand-write.
  • cattrs cannot generate the schema, and --config schema is the deliverable that would
    most justify declaring a model at all. It is also the most expensive of the two viable
    options. Its advantages — pure Python, so no wheel per Python version, and a very stable
    ecosystem — do not pay for that here.

The two-pass design changes the conclusion, and is the reason to take msgspec. An earlier
draft of this section recommended staying hand-rolled, on the grounds that a model would
replace less code than it looked — the adapter-options tail would stay hand-validated
whatever we chose. §3.5's second pass is what dissolves that objection: those options are not
an untyped tail, they are a set the adapter declares, and a struct can be built from the
declaration at run time. Measured, msgspec.defstruct() for a twelve-option adapter costs
0.022ms and validating a profile against it 0.5µs, so the per-adapter model is free
at the point of use.

What that buys is not a tidier version of validation we already do. It is validation of a
surface nothing validates today (§1.5) — the difference between reed_only = true
silently connecting read-write and an error naming the option and the adapter. It also comes
with the declared choices, so sslmode = "verify-ful" is caught by the same pass; and the
same declarations feed --config schema, so one source describes the config, validates it,
and documents it.

So: msgspec, at +37ms on every invocation. Strict where it matters (limit = true is
rejected rather than silently made 1), path-carrying errors, JSON Schema included, Python
≥3.10 and cp310–cp314 wheels — exactly our floor and our matrix. The bet is that it is
pre-1.0 and effectively a one-maintainer project, which is the same class of bet M1
knowingly took on tree-sitter; if it goes bad, the fallback is the hand-rolled version of
the same two passes, which is a contained loss because the design is what matters here and
it survives the library.

What would reverse it: the 37ms mattering more than it looks once --info and --spec are
real, or wanting extra="allow"-style preservation badly enough to prefer Pydantic's cost.
Both are visible in PR 1, which is where this becomes real.

Two questions an earlier draft left open are closed rather than answered. --describe
does not exist, so neither does the question of whether it should report row counts. And
--catalog does not reuse the IDE's catalog cache: catalog_cache.py is kept for
compatibility and is not a live cache to warm or read (Ted). Repeat navigation re-resolves
its prefix, which is one round trip per segment and the price of not serving an agent a
stale catalog to write a query against.


7. Explicitly not in M2

Cut in review, from the roadmap's own M2 list:

  • describe. --catalog --path mydb.analytics.orders already returns the columns of
    orders with their names, real types and quoted identifiers, in one round trip below the
    resolve — which is the whole of what a describe was going to print. The only thing a
    separate mode could add is per-object detail the adapter contract does not carry
    (comments) or that costs a table scan (row counts), and neither is worth a second mode
    that would otherwise be a synonym.
  • hsql fmt. sqlfmt is already a dependency and the wrapper would be small, but the case
    for it is thin: an agent that wants formatted SQL can run sqlfmt itself, and this is the
    one mode that would have forced a 196ms import into a package whose import graph is
    otherwise a tested contract. Cut outright rather than deferred.
  • --dry-run. In practice only DuckDB implements validate_sql at all, and even there
    it is a parse check rather than an existence check: measured on this checkout,
    select * from no_such_table and insert into no_such_table values (1) both validate
    clean, because the adapter deliberately treats every non-parser error as valid so that DDL
    passes. Making it useful would mean a PREPARE pass in the DuckDB adapter (which catches
    the missing table, but rejects DDL as a parser error) plus a refusal path everywhere else —
    a lot of machinery for a flag that would be honest on one adapter. Punted to a later
    milestone, or never.
  • Recursive --depth, the node budget, and child counts (§1.3, §3.2). All three exist to
    make a recursive walk survivable; none is needed if the walk isn't there, and recursion
    should arrive with fetch_descendants() rather than ahead of it. Round-trip counting in
    --stats goes with them: it existed to make a walk's cost visible after the fact.
  • The compact and tree layouts (§3.3). One level is a flat list, so tree has
    nothing to draw, and compact's parent(child TYPE, …) shape only reads for a relation
    and its columns — what a level is belongs to the adapter (§1.2). The same rows through
    -tA --format csv are as compact and stay true at every level.
  • --single-transaction. Adapters name their transaction modes freely and default to
    undefined transaction behavior, so core cannot drive a mode it can't identify; making it
    work would mean reworking the transaction-mode contract for a flag whose job a caller can
    do by putting begin and commit in the script they are already sending.
  • --password-stdin (§3.7). Between profiles, ${VAR} interpolation and the environment
    variables every driver already reads, it fills no gap worth a flag — and the one it does
    fill, it fills by consuming the stream -f - wants.

Deferred to later milestones, as planned: hsql history and hsql open (M4, with the
skill); hsql mcp (M6); streaming and pagination (M5); the interactive password prompt on
harlequin (#667's other half); the agent eval suite (dropped from M1); the site's
llms.txt, raw markdown routes and Docs API (M3). Publishing the JSON Schema at
harlequin.sh/schemas/config/v1.json is a harlequin-web PR that this repo's
--config schema feeds. Unifying the IDE's export dialog onto write_file, and making
textual an optional extra of textual-fastdatatable, both still deferred from M1.


8. Corrections to the product plan

Applied to harlequin-for-agents.md in the same PR as this document; recorded here so the
reasoning behind each is written down.

  • §4 and §6 spell the introspection surface as subcommands. They are mode options on the
    one command — hsql --catalog, hsql --info — which matches harlequin --config and
    avoids ruling on whether hsql catalog means the verb or the database file of that name.
  • §6 says hsql catalog mydb.analytics.orders is "exactly one fetch_children() call."
    It is one round trip per path segment plus one for the listing — four for that example —
    because an item can only be reached through its ancestors (§1.1).
  • §6's --depth N is a knob with a cliff behind it. Its cost depends on the level it
    starts from: depth 2 from the root is one round trip per database, and depth 2 from a
    schema is 403 round trips and 2.3 seconds against a local file. M2 ships one level and no
    flag, and recursion returns with the adapter capability that would make it a single query.
  • §6's describe is catalog on a relation. Listing one level below a relation is its
    columns, so the two would differ only in the per-object detail the contract doesn't carry.
    There is no --describe.
  • §6's child counts have no cheap case. A count is only knowable by fetching the
    children, so the mitigation the product plan proposes cannot be implemented as stated; what
    a listing can report is what it fetched.
  • §6's --format compact is both unimplementable and ungeneralizable. CatalogItem
    carries a 1–3 character type_label, so the type an agent needs is not in the contract at
    all — hence type_name, which the in-tree adapters already fetch and discard (§1.3). And
    the format's parent(child TYPE, …) shape only reads for a relation and its columns; a
    database's schemas are not a signature. There is no compact format; -tA --format csv
    over the same rows is as cheap and is true at every level.
  • §6's capability flags cannot be read off a connection reflectively. SQLite defines
    validate_sql and raises NotImplementedError from it — which the app handles correctly
    today, but which no probe can distinguish from a real implementation. Declaring the flags
    on the adapter class is also what lets hsql --info answer while the database is
    unreachable (§1.4).
  • §5's --timeout needs hsql to attribute the cancellation itself. A cancelled DuckDB
    query returns an empty result set and no error, so a timeout that trusts the adapter
    reports "no rows" and exits 0 (§1.9).
  • §5's --dry-run, --single-transaction and --password-stdin, and §6's fmt, are
    cut
    (§7), so the roadmap's M2 row and §5's flag list both overstate what the milestone
    delivers. --single-transaction is the one worth a sentence in the product plan rather
    than only here: adapters name their transaction modes freely, so story A6's guarantee has
    to come from begin/commit in the caller's own script until the contract says which
    mode is which.
  • §7's config validation runs on the merged document, so no error can name the file it
    came from. Validating each file as it is read, and merging only valid ones, is what makes
    the diagnostics §7 asks for possible (§3.5).
  • §7's config-file merge is per top-level key, and the failure is worse than the docs
    imply.
    A cwd file that defines any profile replaces the home file's whole profiles
    table while leaving its default_profile behind, so both commands refuse to start (§1.6).
    Verified against the pre-refactor config.py as well: this is long-standing behavior, not
    something the tomllib change introduced.
  • §7's ${VAR} interpolation needs to be a read-path transform, because
    harlequin --config reads the config and writes it back — interpolating too early writes
    the resolved secret into the user's file (§1.6).

Plans M3 Hsql Technical Plan (`docs/plans/m3-hsql-technical-plan.md`)

M3 Technical Plan — docs a machine can read, and the skill

Implementation plan for milestone M3 of Harlequin for agents,
following M1 and M2. M1 built
the command, M2 taught it to describe itself. This one is about everything an agent reads
before it runs hsql: the documentation, and the skill that tells it which flag to reach
for.

M3's scope, from the roadmap: llms.txt, llms-full.txt, raw .md routes, Docs API v1,
copy-as-markdown, homepage positioning, and AGENTS.md — Workstream D. The Harlequin skill
(E1) is pulled forward from M4
(Ted's call). The rest of M4 — hsql open, JSONL history,
"Copy CLI command", the external-command hook — stays in M4.

Where M2 actually got to. PRs 1–12, 15 and 16 shipped: the per-file/per-profile config
work, the five self-description modes, secrets and ${VAR}, --catalog, type_name and
--catalog-search. PRs 13 (--read-only and the capability declarations), 14 (--timeout)
and 17 (the safety docs) are in flight and unshipped, which is a real dependency here and not
a footnote: two of the nine things the product plan says the skill should teach are flags that
do not exist yet (§1.9).

Two repos, and most of the work is in the other one. tconbeer/harlequin-web is where
llms.txt, the raw routes, the API and the docs topic live. That repo has no tests, no CI
beyond a Vercel preview build, and a pnpm lint pre-commit hook. M3 is the first milestone
that has to treat it as a build target rather than a place to paste prose.

Bottom line up front. Three claims, each measured below:

  1. Docs written for machines have to be true, and today they are not. hsql --parquet
    and hsql --results are documented on the site, in the packaged README, and in the
    homepage's terminal mock-up; both exit 2 with "No such option" (§1.4). The one page
    written for agents prints &lbrace"status":"ok"… where {"status":"ok"…} belongs (§1.3).
    https://harlequin.sh/schemas/config/v1.json, which every schema hsql --config schema
    generates names as its $id, is a 404 (§1.5). Nothing in either repo checks any of this.
    The first thing M3 builds is the check, not the corpus — publishing more machine-read
    text over an unchecked pipeline multiplies the blast radius of exactly this bug.
  2. A page's markdown source is not its markdown output, and four features want the same
    transform.
    24 of 55 pages open a <script> block; there are 168 <Key> tags, 11
    <Figure src={identifier}> whose src is a JS binding rather than a path, and 56
    relative links that mean nothing outside the site's router. The raw route, llms-full.txt,
    the Docs API and the copy button are four consumers of one sanitizer, and a raw route
    that emits <Figure src={init}> is worse than no raw route (§3.2).
  3. The skill's value is knowing what not to say. It ships in the wheel and prints from
    hsql --skill, so the text an agent installs is the version of hsql that is installed;
    and it teaches --info/--spec/--help -a rather than restating them, so it stays
    ~1,000 tokens and cannot go stale about adapters it has never heard of (§3.5).

1. Where things are today

Measured on this checkout — 2.10.0 plus the unreleased 2.11 catalog work — and on
tconbeer/harlequin-web at 31b2051, built with pnpm 10.33 / Node 22.22 and served from
pnpm preview.

1.1 The site is 55 markdown files, a glob, and no tests

src/docs/**/*.md is the source of truth for both page content and the sidebar: there is no
nav config. src/routes/api/docs/+server.ts globs every .md, reads title and menuOrder
out of the frontmatter, and sorts topics and pages together. Rendering is mdsvex through
src/routes/docs/[topic]/[[page]]/, with ISR at one hour and a hardcoded repoMap for the
third-party adapter star counts.

text
55 docs pages          122,992 bytes of markdown
 3 blog posts
 0 tests                (the repo has no test runner; `pnpm lint` is prettier + eslint)

There is one deployment gate — a Vercel preview build — and it fails only on a build error. A
page that renders a component that does not exist fails it; a page that documents a flag that
does not exist does not.

1.2 What one docs page costs an agent today

Production build, served locally, bytes on the wire for the HTML document alone:

text
/docs/getting-started/usage    66,187 bytes HTML   vs  7,369 bytes markdown source   9.0x
/docs/getting-started/hsql     72,523 bytes HTML   vs  5,761 bytes markdown source  12.6x

The product plan's D2s story claims "a tenth of the tokens," and it is right — most of a page
is the sidebar, which is rendered into every document. The whole docs corpus, with frontmatter
and script blocks stripped, is 116,101 bytes — roughly 29,000 tokens. That number decides
the shape of D1 (§3.3): llms-full.txt is a real artifact but it is not a thing an agent
loads speculatively, so llms.txt plus per-page markdown has to be the primary path and
llms-full.txt the fallback, not the other way round.

1.3 A page's source is not markdown

What is actually in src/docs, counted:

construct count why a raw route can't pass it through
<script> block 24 pages imports; renders as literal text outside Svelte
<Key>ctrl+q</Key> 168 not markdown; means `ctrl+q`
<Tip>, <Note>, <Warning> 22 callouts; mean a blockquote
<Figure src={init} alt=… caption=…> 11 src is a JS binding from the script block, not a path
<Link href="auth"> 9 (5 relative) means a markdown link
<ThemeGallery grow=false> 1 has no markdown equivalent at all
relative [text](../config-file/index) 51 resolves against the site router, not against a .md file
&lbrace; / &rbrace; escapes 4 mdsvex reserves {; the source can't spell one
```output fences 3 a site-private fence language

The brace escape is not hypothetical. src/docs/getting-started/hsql.md:119 writes
&lbrace"status":"ok"… — without the closing semicolon — and the built page serves
&amp;lbrace"status":"ok"…. The published page teaching agents to parse --stats shows
them a JSON object with &lbrace where { belongs.
It has presumably been wrong since the
page was written, because nothing renders that page in a test and no human reads a --stats
line closely.

1.4 The docs disagree with the CLI, and with themselves

Extracting every --flag mentioned in the hsql docs and diffing against hsql --spec (31
core options, plus each adapter's) finds two that do not exist:

text
$ hsql -c "select 1" --parquet -o out.pq
Error: No such option '--parquet'. Did you mean '--path'?          exit 2

$ hsql -c "select 1" --results all
Error: No such option '--results'. (Did you mean one of: '--result', '--stats'?)   exit 2

--results appears in src/docs/getting-started/hsql.md, in packaging/hsql/README.md, and
in the homepage's hsql_features.svelte terminal mock-up. --parquet appears in the first
two. The format shorthands that exist are --csv, --json, --jsonl, --markdown and
--vertical; parquet has never had one, and the correct spelling has always been
--format parquet. Every one of these is a copy-paste-and-fail for an agent, and the
--result case is the worse one: it is one character from the truth and the error names the
right flag, so a careless reader learns the CLI is unreliable rather than that the docs are.

This diff took nine lines of Python. Nothing runs it.

The same is true of the links between pages. Resolving all 56 relative links against the file
tree finds two that 404 on the built site:

text
/docs/hsql                                             404
  from getting-started/running.md:64 — [Using hsql](../hsql)
/docs/duckdb/troubleshooting/duckdb-version-mismatch   404
  from duckdb/index.md:11 — [Troubleshooting](troubleshooting/…), missing a ../

The first is the link from "Running Harlequin" to the hsql page. Two more —
[MySQL/MariaDB](mysql/index) and [ODBC](odbc/index) in adapters.md — resolve only
because the router 308s /docs/x/index to /docs/x (§1.1); they are correct on the site and
dead in any corpus that is files rather than routes, which is exactly what M3 publishes.

1.5 The site is already a publishing target, and nothing publishes to it

src/harlequin/config_schema.py:51 sets

python
SCHEMA_ID = "https://harlequin.sh/schemas/config/v1.json"

/* Detailed source-code truncated for AI context efficiency. */

json
"capabilities": { "implements_cancel": true, "implements_catalog_search": true }

/* Detailed source-code truncated for AI context efficiency. */

text
┌─ /docs/**/*.md            (D2, raw per page)
                        ├─ /llms-full.txt           (D1, the whole corpus)
buildCorpus() ──────────┼─ /llms.txt                (D1, titles + one-liners + links)
  (glob ?raw            ├─ /api/docs/v1/…           (D3, the same markdown, as JSON)
   → sanitize)          └─ "Copy as Markdown"       (D4, fetched from the raw route)

/* Detailed source-code truncated for AI context efficiency. /
output ` becomes an unlabelled fence; `
/
Detailed source-code truncated for AI context efficiency. */

bash
hsql --skill -o ~/.claude/skills/hsql/SKILL.md    # personal, loads on the next session
hsql --skill -o .claude/skills/hsql/SKILL.md      # project, and it commits with the repo

Printing a packaged file imports nothing — no adapter, no config read — and the
import-hygiene guard says so.

Why the wheel rather than a download. The skill an agent installs is then, necessarily,
the skill for the hsql on that machine — the one place "which version am I reading about"
cannot be got wrong. It also works with no network, which is exactly the environment (CI, a
locked-down container) where an agent is most likely to be driving a CLI.

The one-command install, in three tiers. All three serve the same file:

  1. hsql --skill -o …, above. Already one command, already version-matched, works in any
    harness that reads a skills directory. This is what the docs lead with.

  2. A marketplace entry in this repo. A plugin that ships exactly one skill may put
    SKILL.md at the plugin root rather than under skills/, and a marketplace entry's
    source may be a path inside its own repository. So .claude-plugin/marketplace.json at
    the repo root, with one entry whose source is ./src/harlequin/hsql/skill, makes that
    directory the plugin — no second repo, no copied file, no build step. A tiny
    .claude-plugin/plugin.json inside it carries the name, description and version; it ships
    in the wheel too, at a few hundred bytes. Then:

    text
    /plugin marketplace add tconbeer/harlequin      # once
    /plugin install hsql@harlequin

    claude plugin validate ./src/harlequin/hsql/skill is a real command, so this tier comes
    with a CI guard rather than a promise.

  3. The community marketplace. anthropics/claude-plugins-community is public and
    submission is a form; approved plugins are pinned to a commit SHA and the pin is bumped by
    CI as we push. For a user who already has that marketplace — which is most of them — the
    install collapses to a single /plugin install hsql@claude-community, with nothing of
    ours installed first. Tier 2 is the prerequisite, so this is a follow-up to file rather
    than a PR to write.

allowed-tools covers introspection and nothing else. The field pre-approves tools for
the turn that invokes the skill, which is worth having — the first thing the skill tells an
agent to do is run hsql --info, and a permission prompt there is friction on the one command
that is unambiguously safe. So the grant names the read-only modes explicitly
(Bash(hsql --info), Bash(hsql --spec*), Bash(hsql --catalog*),
Bash(hsql --catalog-search*)) and never Bash(hsql *), which would pre-approve
hsql -c "drop table orders". Deciding whether to run a query is the human's; deciding
whether to read a catalog is not.

Its own progressive disclosure is the CLI. A skill's third layer is normally bundled
reference files. Harlequin's references are commands — --info, --spec, --help -a NAME,
--catalog — which are current by construction and cost nothing until they are run. That is
why the skill can be one file under a fifth of the 500-line ceiling, and why it does not rot
when an adapter we have never heard of is installed beside it.

What it says, in nine short sections, budget ≤4KB / ~1,000 tokens:

  1. Ask before you assume. hsql --info for versions, config files, the active profile and
    what each adapter supports; hsql --help -a NAME for one adapter's connection options;
    hsql --spec when a machine needs the option list rather than a human.
  2. Never put a credential on the command line. Use a profile and -P; use ${VAR} in the
    config file; the shell history and the process table are both readable.
  3. Orient in the catalog before writing SQL. hsql --catalog, --path db.schema,
    --path db.schema.table for columns, --catalog-search TERM when you don't know where
    something lives — and check --info first, because searching is a capability not every
    adapter has.
  4. Run it. -c for a statement, -f for a file (-f - for stdin), --result to pick
    which result set prints, --on-error to pick what happens after a failure.
  5. Pick a format on purpose. -tAc for one value into a shell variable; --csv for a
    pipe; --markdown when the output is going into your own reply; --format parquet -o for
    anything large. --format takes the name; only csv, json, jsonl, markdown and vertical
    have shorthands.
  6. The row limit is real. 500 by default. --stats puts {"truncated": …} on stderr —
    read it, and never 2>/dev/null, because that is where truncation warnings and errors both
    go.
  7. Branch on the exit code. 0 ok, 1 query error, 2 usage/config error, 3 connection error,
    4 timeout, 130 interrupted. A usage error is your bug; a query error is the SQL's.
  8. Ask before you write. Prefer --read-only where the adapter declares it (--info
    again); say what a DDL or DML statement will do before running it.
  9. Know when to hand off. Schema you can't disambiguate, a query the human will want to
    iterate on, anything destructive, anything that wants a human eye on 10,000 rows: stop and
    tell them to open harlequin -P — same profile, same adapter, same engine.

What it deliberately does not contain: the adapter list, any adapter's options, the full
format list, the option table, or anything about the config file's schema. All five are
one command away, and all five are what makes a skill rot.

Section 9 is the one nobody else would write, and it is also the one M4 improves: with
hsql open query.sql, "tell them to open Harlequin" becomes "hand them the query, loaded and
unexecuted." The skill gets that paragraph in M4, and the plan should not pretend the M3
version is the finished one.

The body is written as standing guidance, not a procedure. A skill's content enters the
conversation once and stays for the session; the file is not re-read on later turns. So each
section reads as a rule that holds for the rest of the work ("the limit is real; check
truncated every time"), not as a step that happens once ("first, run --info"). Nine
sections of standing rules is also why the skill is nine short sections rather than a
walkthrough.

The description is the product. It is the only part always in context, and it is what
decides whether the skill loads at all — a skill that never triggers is worth exactly nothing,
and the current failure mode is under-triggering, not over. So it names the contexts as well
as the capability: running SQL, inspecting a database's tables or columns, exporting query
results, a .sql file, a connection string or DSN, a harlequin.toml — including when the
user never says "hsql." It gets one deliberate pass with the description-optimization loop in
skill-creator (about 20 realistic trigger queries, half of them near-miss negatives, split
train/test) before PR 3 merges. That loop needs the claude CLI, so it is a pre-merge
exercise whose eval set is committed, not a CI check — and it is the closest thing this
milestone has to the agent eval suite M1 dropped (§5).

3.6 The "Headless & Agents" topic

D5, in src/docs/headless/, with index.md carrying topic: "Headless & Agents". The
topic is additive: getting-started/hsql.md stays exactly where it is
(Ted's call). It is
the page a human lands on — linked from the packaged README, the homepage and
running.md — and it earns that spot: it is the tutorial. The new topic is the reference
beneath it, and it is where a page goes when it is too long or too machine-facing to belong in
Getting Started. No moves, no stub, no redirect, and nothing in the wild breaks.

page source
index.md — what the topic is, and the path through it new; links up to the tutorial
exit-codes.md — the six codes, --stats, --on-error, what goes to stderr new; today only the packaged README says this
catalog.md — --catalog, --path, --catalog-search, and the capability new on the site; the packaged README has it
formats.md — every layout and file format, -o, the -tAc idiom expands the tutorial's list into a reference
config.md — the config spec, ${VAR}, secrets, the --config modes new; links across to the config-file/ topic
safety.md — --read-only, --timeout, the capability flags M2's PR 17 content, if it has not landed
psql.md — the differences table M2's PR 17 content
reference.md — the generated CLI reference §3.1b, vendored
skill.md — what the skill is, and the three ways to install it §3.5
mcp.md not in M3 — M6

The tutorial keeps its shape and gains links out: each section that currently ends by
summarizing something ("hsql's exit codes are meaningful and stable," the format list) points
at the reference page that now covers it in full. The rule for which side of the line a fact
falls on is whether a human reads it once or an agent reads it every time.

3.7 Copy and view as markdown

In the docs layout, beside the page title: a "Copy as Markdown" button and a "View as
Markdown" link to the page's own .md URL. The copy variant prepends the canonical URL and
title as a two-line header, so a pasted page carries its provenance. The button fetches the
.md route rather than re-deriving anything — one sanitizer, five consumers, and this is
consumer five.


4. Sequencing

Two repos. The harlequin PRs go first, because the site's new pages vendor their artifacts,
and because the skill cannot honestly say "prefer --read-only" until M2's PR 13 has shipped.
Numbering continues within this milestone; harlequin-web has no version numbers, so its PRs
are listed in dependency order and deploy continuously.

Release A — truth and the skill (`harlequin`, 2.13)

PR 1 — The flag guard, and the flags it catches. The extractor and the test (§3.1a), plus
the fixes it forces: --results → --result and --parquet → --format parquet in
packaging/hsql/README.md. Fails on main today. Bug Fixes entry — this is a documented
command line that does not run.

PR 2 — The generated CLI reference. scripts/write_cli_reference.py,
docs/generated/hsql-reference.md, and the regenerate-and-compare test, modeled on
test_config_schema.py. No user-facing behavior; the artifact is what PR 5 publishes.

PR 3 — hsql --skill, and the skill. The skill text as package data,
hsql/modes/skill.py, -o support, the import-hygiene assertion that it loads no adapter,
the frontmatter and size guards (§5), and the flag guard extended to cover it. The
description gets its optimization pass before merge, with the trigger eval set committed
beside the skill (§3.5). Lands after M2's PR 13, so §3.5's section 8 is true when it
ships. Features entry.

PR 4 — The marketplace entry. .claude-plugin/marketplace.json at the repo root and
.claude-plugin/plugin.json beside the skill, making src/harlequin/hsql/skill a
single-skill plugin with no copied file (§3.5, tier 2), plus claude plugin validate in CI.
Small, and separable from PR 3 so a problem with either lands alone. Features entry — the
install line is the user-facing part.

PR 5 — The publishing workflow. The release.yml step that copies config-v1.json,
hsql-reference.md and SKILL.md plus a manifest.json into a PR against harlequin-web
(§3.4). No changelog entry; it is release plumbing.

Release B — the site (`harlequin-web`, continuous)

PR 6 — /schemas/config/v1.json, and the artifacts directory. The vendored files and the
routes that serve them. First, and separately from everything else, because it fixes a URL
that has been live and broken in every generated schema since 2.10 (§1.5).

PR 7 — The corpus and the sanitizer. src/lib/server/docs.ts, and the repo's first test
runner (vitest): golden files for four representative pages — one with a script block and
figures, one dense with <Key>, one with a callout and relative links, one plain — plus the
docs lint (§3.1c). The lint fails until the two 404 links in §1.4 are fixed, so they are
fixed here. Otherwise nothing user-visible ships in this PR; it is the foundation the next
four stand on.

PR 8 — Raw markdown per page. /docs/{topic}/{page}.md, prerendered, text/markdown.

PR 9 — llms.txt and llms-full.txt. Plus the robots.txt lines and, for pages that
want one, the description frontmatter key.

PR 10 — Docs API v1. The index and the per-page route, CORS, /api/docs untouched. No
search (§6).

PR 11 — Copy and view as markdown. The layout buttons.

PR 12 — The Headless & Agents topic. The nine new pages in §3.6, the links out from the
tutorial, the vendored reference page and the skill page. getting-started/hsql.md stays
where it is. The largest content PR in the milestone, and the one that most wants a careful
read rather than a fast one.

PR 13 — The homepage correction. --results in hsql_features.svelte, and a link from
the #hsql section to the new topic.

Release C — the ecosystem

PR 14 — AGENTS.md in harlequin-adapter-template. D7's remaining half: repo layout, the
adapter contract's required members, how to run tests, and — new since the product plan was
written — type_name, search_catalog() and the capability flags M2 added, so an adapter
written from the template declares them rather than discovering them later.

And one follow-up to file, not to build: the community-marketplace submission (§3.5,
tier 3). It is a form, it depends on PR 4 being live, and it is reviewed on someone else's
schedule, so it does not belong in a PR list.

Ordering rationale. Same as M1 and M2: the guard lands before the thing it guards, the
artifact lands before the page that publishes it, and nothing that needs another repo's
release blocks anything that doesn't. The one hard dependency across milestones is PR 3 on
M2's PR 13.


5. Testing

The M1/M2 guards stay. What M3 adds is the first coverage either repo has had of the text
humans and agents read, which until now has been the only untested output Harlequin
produces.

  • Every long flag in the packaged README and in the skill exists in hsql --spec. With a
    small allowlist for other programs' flags (uv's --with, pytest's --snapshot-update),
    asserted by name so an addition to the allowlist is a visible decision.
  • hsql --skill is byte-identical to the packaged file, and writing it with -o produces
    the same bytes as stdout — the M1 property ("the bytes are the contract"), applied to the
    one output that is not a result set.
  • hsql --skill imports no adapter, no tomlkit, no questionary. A subprocess reading
    sys.modules, like every other import-hygiene test, with a matching ignore_imports entry.
  • The skill's frontmatter uses only the six spec fields, parses as YAML, and carries a
    non-empty description; the body stays under 200 lines and 4KB. The field check is what
    keeps the file loadable outside Claude Code (§3.5), and the size check is what keeps a later
    edit from quietly turning a skill into a manual.
  • claude plugin validate passes on src/harlequin/hsql/skill, and the marketplace entry
    names a source directory that exists. In CI where the claude CLI is available; skipped,
    not silently passed, where it is not.
  • The generated reference regenerates identically. test_cli_reference.py runs the
    generator and compares to the committed file, exactly as test_config_schema.py does.
  • Sanitizer goldens. Four pages, checked in as expected markdown. A component tag the
    sanitizer does not know throws, and there is a test that asserts it throws — the degradation
    path is the one nobody exercises.
  • No sanitized page contains a <Capitalized tag, an unresolved {identifier}, &lbrace,
    or a relative link. Asserted over the whole corpus, so it covers pages written after this
    milestone.
  • Every internal link in the sanitized corpus resolves to a page the corpus contains —
    after the x/index normalization, so a link the router rescues today is still checked. It
    fails on the two 404s in §1.4, and it is what stops the §3.6 moves from leaving holes.
  • llms.txt lists exactly as many pages as src/docs holds, so a new page cannot be
    silently missing from the index an agent reads first.
  • The .md route and llms-full.txt agree byte for byte for the same page. They are two
    views of one corpus, and the moment they disagree, one of them is a second sanitizer.
  • The vendored artifacts parse. config-v1.json is valid JSON Schema; manifest.json
    names a version that matches the reference page's generated-from line.

Not tested, still: whether an agent that reads the skill picks the right flag first try. That
is the agent eval suite dropped from M1 (§13 of the product plan), and M3 is the milestone
where it would finally have a stable surface to run against — the skill is a fixed text, and
--spec makes the expected-flag assertions cheap to write. It stays out of scope here, and it
is the single most valuable thing that could be added to it.


6. Decisions

Settled.

  • The skill ships in the wheel and prints from a mode option. hsql --skill, consistent
    with M2 §3.1 — modes are options, not subcommands. One copy of the text, version-matched to
    the installed CLI, and no network needed to install it.
  • The skill teaches the questions, not the answers. No adapter list, no option table, no
    format list; --info, --spec and --help -a instead. This is what keeps it ~1,000 tokens
    and what keeps it from rotting.
  • One sanitizer, one corpus. The raw route, llms-full.txt, the API and the copy button
    all call buildCorpus(). A second markdown-producing path is the same mistake as a second
    normalizer in query.fetch().
  • llms.txt is the front door; llms-full.txt is the fallback. 29,000 tokens is not an
    opening move.
  • .md suffix routes, not Accept negotiation (§3.3).
  • /api/docs is untouched and v1 is additive. The sidebar depends on the old shape,
    including its "/src" quirk; a fix there is a rendering change, not a docs-for-machines
    change, and it does not belong in this milestone.
  • Artifacts are vendored into the site by a release workflow, not fetched at build time
    (§3.4) — recommended, and the one worth Ted's explicit yes before PR 5.
  • Prerender, don't ISR, for everything M3 adds. The corpus changes when the repo does.
  • Docs API v1 ships no search. The index plus a page fetch is what an agent actually does,
    and a mediocre search endpoint is a support surface with no consumer asking for it.
  • No updated_at in the API payload. Nothing in the repo can supply one honestly — no
    date in the frontmatter, no git history on a shallow Vercel checkout — and a field that
    lies is worse than a field that is missing.
  • getting-started/hsql.md stays where it is, and Headless & Agents is additive (§3.6).
    The tutorial is the page a human lands on and the page the README links to; the new topic is
    the reference underneath it. No moves, no redirect.
  • The skill's frontmatter is the six-field Agent Skills spec, and nothing else (§3.5). A
    skill whose argument over MCP is "it works in any harness" does not get to ship a
    vendor-locked file.
  • The description gets a deliberate optimization pass, with its eval set committed (§3.5).
    It is the only part always in context and the only thing that decides whether the skill
    loads at all.
  • allowed-tools names the introspection modes and never Bash(hsql *). Reading a
    catalog without a prompt is a convenience; running arbitrary SQL without one is not a
    decision this skill gets to make for a user.

Open.

  • Do we submit to the community marketplace, and when? Tier 3 in §3.5 is the only install
    that is genuinely one command for a user who has installed nothing of ours, and it costs a
    form rather than a repo now that the marketplace entry lives in this repo. The real cost is
    what it commits us to: a skill in a public catalog is one more surface with a compatibility
    expectation. Recommend filing it once PR 4 is live and the skill has survived one release.
  • Does the release workflow open the harlequin-web PR, or does a human run the script?
    §3.4 assumes automation, which needs a token with write access to the other repo. A make publish-artifacts that a human runs at release time is the same script without the
    credential, at the cost of a step someone can forget.
  • Does anything besides the skill and the reference want to be a vendored artifact? The
    packaged README is the obvious candidate — it duplicates a good deal of what the new topic
    will say — and the answer is probably to shrink the README rather than publish it twice.

7. Explicitly not in M3

  • hsql mcp — M6, and the skill is deliberately the thing that ships first (product plan
    §9). The mcp.md page waits for it.
  • hsql open, hsql history, "Copy CLI command", the external-command hook — the rest of
    M4. Only E1 was pulled forward. §3.5's section 9 is the paragraph M4 rewrites.
  • Streaming and pagination — M5.
  • An agent eval suite — still out (§5), still the thing most worth adding back.
  • Docs search, in any form (§6). Not the endpoint, and certainly not a vector index over
    55 pages: llms.txt plus a page fetch is the search.
  • Docs versioning. Per-release snapshots of the docs site, so an agent can read the docs
    for the version it has installed. Real, and much bigger than this milestone: it changes every
    route. The manifest.json version line (§3.4) is the cheap partial answer.
  • Auto-generating adapter pages from each adapter's --spec. It would need every
    out-of-tree adapter installed at site build time, which is the hermetic-build problem again,
    multiplied by twelve.
  • Bundled reference files, scripts or assets in the skill. Its third layer is the CLI
    (§3.5). The day that stops being enough is the day references/ earns its place, and not
    before.
  • Fixing the site's sidebar API shape (§1.6's "/src"), and the getting-started/hsql.md
    brace bug in the rendered page. Both are one-line fixes and both are rendering bugs, not
    machine-readability ones; they ride along in PRs 6 and 12 respectively rather than earning
    their own scope.

8. Corrections to the product plan

Recorded here; applied to harlequin-for-agents.md in the same PR as this document.

  • §9's E1 outline uses spellings that do not exist. hsql info --json, hsql spec --json
    and catalog --depth 2 were all written before M2 settled on mode options. The real
    spellings are hsql --info and hsql --spec (both JSON, always), and --catalog with
    --path, plus --catalog-search TERM. --depth was cut from M2 outright (M2 §7), so
    "orient with catalog --depth 2" describes a flag that will not exist until
    fetch_descendants() does.
  • §9 calls the skill "one markdown file" and leaves it there. It is a file with a
    contract: Agent Skills is an open standard, its portable frontmatter is six fields, and the
    description is the part that decides whether the skill is ever loaded. §9's own argument for
    skill-before-MCP — that it works in any harness — is only true if the file is written to
    that standard rather than to one client's extensions (§3.5).
  • §9 says the skill is "published on the site, and installable" without saying how.
    Installable now means three things, and they are not equivalent: hsql --skill -o from the
    wheel, a marketplace entry that this repo can host without a second repo or a copied file,
    and a community-catalog listing. The first is the one the docs lead with, because it is the
    only one that cannot disagree with the installed CLI (§3.5).
  • §9's "Prefer --read-only" is conditional on a capability. Read-only is declared per
    adapter (M2 §3.4), so the skill has to say "where the adapter declares it, which --info
    will tell you," not "always."
  • §8's D6 and D7 largely shipped during M1/M2. The homepage has its two-command section
    and its #hsql block; AGENTS.md is in both harlequin and harlequin-web. What is left
    is a one-word correction on the homepage and AGENTS.md in the adapter template. The
    roadmap's M3 row overstates the remaining work by two of its seven items.
  • §8's D1 treats llms-full.txt as the primary affordance — "an agent fetches one URL and
    knows everything." Measured, that URL is ~29,000 tokens. llms.txt plus per-page .md is
    the primary path; llms-full.txt is for the caller who genuinely wants the corpus.
  • §8's D3 asks for updated_at, which nothing can supply honestly. There is no date in
    the frontmatter and no git history on the build machine. Drop the field (§6).
  • §8's D3 search endpoint is cut. Over 55 pages and 116KB, llms.txt plus a page fetch
    is the search, and it is the thing an agent will reach for anyway. v1 is the index and the
    page (§6).
  • §8's D2 says "the real work is sanitization" and understates it by one case. The hard
    one is not the components, it is that <Figure src={init}> resolves through an import in the
    <script> block the sanitizer is deleting — so the transform has to read the block before it
    removes it (§3.2, steps 2 and 4).
  • §8 does not mention that the site is already a publishing target. hsql --config schema
    has been emitting $id: https://harlequin.sh/schemas/config/v1.json since 2.10, and that
    URL 404s. Publishing generated artifacts is a Workstream D deliverable that the roadmap
    never listed (§1.5, §3.4).
  • §8 assumes the docs are correct, and they are not. Two flags documented in three places
    exit 2, two internal links 404 (§1.4), and the page written for agents renders &lbrace
    where { belongs (§1.3).
    The workstream's first deliverable is a check, not a corpus — otherwise M3 publishes the
    same errors through four more channels.

Plans M4 Hsql Technical Plan (`docs/plans/m4-hsql-technical-plan.md`)

M4 Technical Plan — the handoff, and the two hooks

Implementation plan for milestone M4 of Harlequin for agents,
following M1, M2 and
M3. M1 built the command, M2 taught it to describe itself, M3
wrote the docs and the skill an agent reads before it runs. This one is Workstream F: what
happens when the agent and the human need to hand work to each other.

M4's scope, from the roadmap: the skill, hsql open, JSONL history, "Copy CLI command",
and the external-command hook. The skill (E1) shipped in M3 — it is in the wheel, prints
from hsql --skill, and installs as a plugin — so what is left is the four handoff features.
Two of those turn out to be three: the external-command hook covers two requests that share
almost nothing, and this plan splits them (§6.1). And "JSONL history" turns out to name a
storage format that the History screen's missing search rules out (§6.4).

The issues M4 closes. #1102, external
editor support, was opened while this plan was being written and carries a reference
implementation from Posting. #952,
natural language to SQL, is closed not planned — "I won't be adding this to Harlequin" — and
the command hook is the answer that stays true to that: Harlequin runs the tool the user
already trusts, with the user's own credentials, and embeds no model (§8).
#850, the History screen's UI, and
#429, search in that screen, ride along
with the release that changes what it reads — and #429 is what decides how the history is
stored at all (§3.2, §3.4).

Three releases, and the editor and the hooks go first (Ted's call). Nothing in this
milestone depends on anything else in it, so the order is a product decision: A is the editor
and the hooks, B is query history and search, C is the handoff (§4).

Bottom line up front. Three claims, each measured below.

  1. The handoff between the two commands is a process boundary, and everything cheap about
    it is on the far side of one.
    hsql --open cannot put a buffer in front of a human
    in-process: harlequin.editor_cache imports Textual — 341 modules, +164ms over a bare
    interpreter, against a whole hsql --version of 118ms — and it is rewritten whole at
    action_quit, so a second writer would race a running IDE for the file that holds every
    open buffer (§1.1). --open execs a documented flag on the other command instead, and the
    argv it builds is the same module the TUI's "Copy CLI command" reads backwards (§3.1, §3.5).
  2. The query history both commands should share cannot be either of the things that hold it
    today, and search is what settles what replaces them.
    The IDE's history is a pickle
    inside the catalog cache, written once, at quit — so a crash, a kill, or a container that
    goes away loses the session — and it lives in harlequin.history, which imports rich,
    which is on the headless FORBIDDEN list and costs +46ms on a 118ms command (§1.2, §1.3).
    The store both commands can write is a SQLite database, not the JSONL file the roadmap
    names: with #429 in scope, searching
    100,000 records costs 396ms as a file scan and 35ms as a like, and 400ms per
    keystroke is not a filter anyone can type into. sqlite3 is stdlib, imports cheaper than
    json (+4.0ms against +7.6ms), and costs 0.020ms a row to write (§3.2).
  3. The external editor and the AI hook are two features, and the terminal is why. One
    needs the terminal and must suspend the app; the other must not have it and must not block
    the app. They differ in thread, in exchange medium, in failure mode, in what cancels them,
    and in whether the user's query leaves the machine (§6.1). And a shell command that came
    from a config file is not the same as one that came from $EDITOR: config files merge from
    the current working directory, so a repository you cloned can define one (§1.7). The
    environment is the user; a file is not.

1. Where things are today

Measured on this checkout — 2.12.2, the release the M2 safety work closed — with Python
3.10.15 on Linux, textual 8.2.8, textual-textarea 0.18.1. Timings are the minimum of five
subprocess runs; a bare python -c "import sys" is 12.7ms on this machine and is subtracted
where an import cost is given.

1.1 Nothing outside the IDE can put a query in front of a human

harlequin takes CONN_STR positionally and has no file argument. -f is not free: it is
--show-files, the directory the Data Catalog shows in a file tree. The only way to get a
.sql file into a buffer today is code_editor.load — the editor's Open Query action, a path
input inside a running app.

The product plan's note that "buffers already persist through editor_cache.py, so the
machinery is close at hand" is the one part of §10 that measurement contradicts, in both
directions that matter:

text
import harlequin.editor_cache    175.1ms   (+164.4ms)   341 modules   textual: yes
import harlequin.history          59.0ms   ( +45.9ms)   142 modules   textual: no, rich: yes
import harlequin.catalog_cache    (rich: yes, via harlequin.history)

editor_cache.BufferState holds a textual.widgets.text_area.Selection, so reading or
writing that cache imports the TUI. And the cache is not an append-only store: action_quit
writes Cache(focus_index, buffers) — every open buffer, all at once (app.py:963). A second
process appending a draft to it would be writing the file a running IDE is about to overwrite
from memory.

So the handoff cannot be "hsql writes the buffer cache." It has to be an argument to the other
command.

1.2 History is a pickle, written once, at quit

History is a deque(maxlen=500) of QueryExecution records, per connection hash, stored
inside CatalogCache.history and pickled to the user cache dir. It is loaded on
CatalogCacheLoaded and written in exactly one place: action_quit (app.py:965-977),
alongside the editor cache. Every append_to_history() between start-up and quit lives only
in memory.

Three consequences, all of which M4 has to deal with:

  • A session that does not exit cleanly has no history. A crash, a kill, a closed
    terminal, a container reclaimed under a remote session: the queries are gone.
  • hsql writes nothing. An agent can run forty queries against the human's database and
    leave no trace the human's History screen will ever show. This is Workstream B's "closes the
    loop the other way", and it is currently closed in neither direction.
  • A pickle is not a thing "both agents and humans can read with ordinary tools" — the
    product plan's own criterion. Reading it requires importing harlequin.history to unpickle
    the dataclass, which imports rich.

It also cannot be searched. The History screen is an OptionList of at most 500 records with
no filter input, so "what was that query I ran last week" is answered by scrolling —
#429, open since January 2024. Search is
the requirement that decides the storage (§3.2), which is why it belongs in this milestone
rather than after it.

And the screen that shows it has other problems, filed as
#850. HistoryScreen subclasses Screen,
not ModalScreen — and ModalScreen is the one whose "bindings take precedence over the
App's", so every app-level key is live behind a screen whose job is to pick one query. Its
preview pane is a TextEditor with read_only=True, which stops edits but not focus: Tab or a
click puts a cursor in a query the user cannot change. Ted's reading of the issue is that the
interactivity of the preview is the problem, not the missing key list.

1.3 `harlequin.history` is a rendering, and headless code may not import it

QueryExecution.__rich__ builds a Group of a Columns and a Text: the History screen's
row, in the dataclass that holds the record. tests/unit_tests/test_import_hygiene.py lists
rich in FORBIDDEN for every headless import, for the reason the module docstring gives —
nothing headless renders — and it is the guard that caught rich arriving sideways through
textual_fastdatatable before 0.17.1 deferred it.

Measured, that guard is not ceremony: harlequin.history costs +46ms, which is 39% of a whole
hsql --version invocation:

text
hsql --version         114-119ms
harlequin --version    621-636ms

harlequin.catalog_cache imports harlequin.history, so it carries rich too — which means
get_connection_hash(), the function that decides which history a session gets, is also
unreachable from hsql today. Whatever writes the shared log has to live below both.

1.4 Nothing in the app knows what command line started it

"Copy CLI command" has to render an hsql invocation that reproduces what the human is
looking at. The app cannot: Harlequin.__init__ receives an instantiated adapter, plus
profile_name, connection_hash, and the TUI's own settings. conn_str is consumed in
cli.py and handed to the adapter's constructor; no attribute on HarlequinAdapter carries
it — adapters store it themselves, and the contract does not require them to. --config-path,
-a, --read-only and any adapter options typed on the command line are gone by the time the
app exists.

The app also never calls redact.hide_secrets_in(). hsql does, on every run; the IDE does
not, so redact_text() in an IDE process masks nothing, because _HIDDEN is empty. Anything
M4 adds that prints or copies a value — a clipboard string, a log line — needs that call to
have happened.

1.5 Actions are a fixed registry, and an unknown name crashes the app on mount

HARLEQUIN_ACTIONS maps ~90 action names to (target widget class, action method).
Keymaps — plugin or config — map keys to those names, and action_bind_keymaps() /
bind_keys() look each one up with HARLEQUIN_ACTIONS[binding.action]. keymap.py carries
the TODO: # TODO: ADD VALIDATION when creating bindings from config.

Measured, with a keymap whose only binding names code_editor.formt:

text
KeyError: 'code_editor.formt'
  in action_bind_keymaps, during on_mount

The app dies in mount with a traceback, on a typo in a config file, and harlequin --keys —
the tool for fixing keymaps — is a second Textual app that reads the same registry. M4 makes
action names come partly from config (one per configured command), which turns this from a
papercut into the failure mode of a new feature.

1.6 Nothing runs a subprocess, `suspend()` exists, and tests cannot reach it

There is no subprocess, os.system or $EDITOR anywhere in src/. Textual's
App.suspend() is a synchronous context manager that publishes a suspend signal, drops out of
application mode, redirects stdout/stderr back to the real ones for the duration, and
resumes with refresh(layout=True). Two things about it shape §3.6:

  • It is main-thread only, by construction: it redirects the process's streams and drives
    the driver. Nothing in a @work(thread=True) worker may call it.
  • It is unavailable in tests. can_suspend is True on the Linux and Windows drivers and
    False on the base driver, which the headless driver inherits. Measured inside run_test():
text
DRIVER: HeadlessDriver can_suspend: False
RAISED: SuspendNotSupported App.suspend is not supported in this environment.

So the external-editor path needs a seam a test can patch, and the SuspendNotSupported
branch is itself a user-visible behavior to test rather than an assertion that never fires.

1.7 Config files merge from the working directory, so a config file is not the user

load_config() reads, in priority order, an explicit --config-path, then ./.harlequin.toml
and ./pyproject.toml, then the user config dir, then home — and merges them, nearest file
wins, per profile and per keymap. A repository you clone can therefore supply a profile, a
keymap, and (today) a duckdb init_path, which runs SQL at connection time.

That is already a real trust surface, and it is bounded: arbitrary SQL against a database the
user chose to open. A [commands] table would make it arbitrary code as the user, from a file
they may never have read — and, as Ted put it, a git-synced dotfiles repo makes the same true
of a file in the user's own config dir. $EDITOR, by contrast, is the user's own shell
session. That asymmetry is the whole design of §3.8.

1.8 What the skill can say about handing off, and what it cannot

M3 shipped the skill with section 9, "Know when to hand off," and it currently reads: stop,
and tell the human to run harlequin -P . That is the best available advice for an
agent holding a draft migration, and it loses the draft: the human gets an empty editor and
the agent's SQL is in a transcript somewhere above. §3.1 is what lets that paragraph say
hsql --open -f draft.sql -P prod instead, and M3's §7 already names it as the paragraph M4
rewrites.


2. The obstacles, stated plainly

  1. The two commands cannot share a process. One is a TUI that owns the terminal; the other
    must not import Textual. Every handoff feature in M4 is therefore an argv, a file, or a
    log — never an import.
  2. The store that both commands write has to be below rich. Not harlequin.history, not
    catalog_cache, and not a pickle of a dataclass whose __repr__ is a Rich renderable.
  3. The shared store has more than one writer and now has to be searched. Concurrency
    alone an append-only file survives; search over all of it — not a bounded tail — is what it
    cannot serve at an interactive latency.
  4. The app does not remember how it was started, and half of what it would need to
    remember is secret.
  5. Running someone else's program from inside a Textual app has two incompatible shapes,
    and picking one shape for both produces a config whose validity table is mostly errors.
  6. A config file can now ask Harlequin to execute a shell command, and config files arrive
    from directories the user did not audit.
  7. A user-defined action name has to exist before a keymap can bind it, and today an
    action name that does not exist is a crash rather than a message.

3. Target architecture

Six deliverables, three new modules and one new mode, all of the new modules below rich:

text
harlequin/
  invocation.py        # what a command line said; renders back to either command's argv
  query_log.py         # the query history: one SQLite store, written by both commands
  external.py          # running someone else's program: the two shapes, and the trust gate
  hsql/modes/history.py  # the store, as a result set, like every other mode

invocation.py and query_log.py are importable by hsql (stdlib plus platformdirs and
harlequin.redact), and modes/history.py is imported by the callback only when the mode is
chosen, as every mode is. external.py is the IDE's, and imports Textual only under
TYPE_CHECKING; the app owns the widgets, this module owns the process.

3.1 `hsql --open`, and the flag on the other side

A mode option, not a subcommand. M2 settled that (hsql catalog versus a DuckDB file
named catalog); --open needs no new rule, and reuses the SQL sources the command already
has. All three of these work, because -c, -f and stdin are already one layer:

bash
hsql --open -f migration.sql -P prod      # the file, in the human's IDE
hsql --open -c "select * from orders …" -P prod
claude -p "write the backfill" | hsql --open -P prod

The other side is a documented flag, not a private channel. harlequin --open PATH
(repeatable) loads each file into a new buffer, activates the last, and executes nothing.
It is worth having on its own: harlequin --open report.sql is what a human wants too, and
-f was never available for it (§1.1).

What crosses the boundary. --open does not connect to a database — it is a handoff, not
a run — so what it forwards is the connection description: -P, --config-path, -a when
it was typed, --read-only, and CONN_STR. Adapter options typed on the command line are
rendered back from their declarations (AbstractOption, M2's to_dict()), with one refusal:
an option declared secret=True is not forwarded. hsql exits in a second; the IDE it
launches lives for hours, and ps shows an argv for as long as the process holds it. The
error names the fix, which is the fix the skill already teaches:

text
hsql: --password cannot be forwarded to the IDE. Put it in a profile and pass -P.

Launching. [sys.executable, "-m", "harlequin", *argv], never a PATH lookup:
harlequin.__main__ exists, python -m harlequin --version works today, and the interpreter
running hsql is by definition the one that has Harlequin installed. On POSIX, os.execv
replaces the process — no second interpreter resident under the IDE, and signals and exit
status belong to the app that owns the screen. On Windows, os.execv returns control to the
shell while the child still runs, so there it is subprocess.run() and the child's return
code.

The terminal has to be handed over too, and this is the part that is easy to get wrong.
After cat q.sql | hsql --open, fd 0 is an exhausted pipe; the IDE would start and read no
keys. So --open reattaches 0/1/2 from the controlling terminal (/dev/tty, CONIN$/
CONOUT$ on Windows) whenever they are not a TTY, and refuses with a usage error when there
is no controlling terminal to reattach to — a CI job that pipes into --open gets a sentence,
not a hung process.

The scratch file. -c and stdin have no path, so --open writes one:
/harlequin/open/YYYYmmdd-HHMMSS-.sql, pruned of anything older than
seven days on each invocation. State, not cache: an unsaved draft is not disposable. A query
that came from -f is passed by its own path — the IDE copies text into a buffer and never
writes back, so the human's file is not touched either way.

3.2 `harlequin.query_log` — one store, two writers, and why it is SQLite

One database, at /harlequin/history.db, one row per statement:

sql
create table queries (
  id          integer primary key,   -- insertion order, which is also newest-last
  run_at      text    not null,      -- UTC, ISO-8601
  program     text    not null,      -- 'hsql' or 'harlequin'
  connection  text,                  -- the id the caches key on
  profile     text,
  adapter     text,
  sql         text    not null,
  status      text    not null,      -- 'ok' | 'error' | 'canceled'
  rows        integer,
  truncated   integer,
  elapsed_ms  real,
  error       text
);
create index queries_connection_at on queries (connection, id desc);

Search is what decides the format — §1.2 and
#429. A tail read is bounded by
construction — the newest 500 — so an append-only file serves it well. Search is the
opposite: its whole value is that it reaches queries you ran weeks ago, which means reading
everything. Measured over 100,000 records (52 MB of JSONL, 45 MB of
SQLite, both warm in page cache):

JSON Lines SQLite
newest 500 for one connection 8.2ms (hand-written reverse scan) 16.7ms (order by id desc limit 500)
search all of it, newest 500 matches 396.4ms (full scan + parse) 34.9ms (sql like ?)
one record written 0.006ms (os.write, O_APPEND) 0.020ms (WAL, synchronous=normal)
import cost +7.6ms (json) +4.0ms (sqlite3)

The tail row is a wash and the write row is 14µs on a query that cannot take less than a
millisecond. The search row is the milestone: 400ms per keystroke is not a filter anyone can
type into, and it is 400ms only because the file was in cache. sqlite3 is cheaper to
import than json, is stdlib, and is already the module harlequin_sqlite drives.

Pragmas are part of the design, not tuning. journal_mode=wal and synchronous=normal
are what make a write 0.020ms; the defaults measure 0.678ms, a 34× difference that would show
up on a script of a thousand statements. busy_timeout=5000 is what makes concurrent writers
a non-event: 8 processes × 200 inserts produced 1,600 rows, 0 errors, in 0.15s wall. WAL needs
shared memory, which a network home directory does not have, so a failure to enter WAL falls
back to the default journal — still 0.678ms, still correct.

A schema change is an alter table, not a new file. pragma user_version plus an ordered
list of migrations, which is the thing the pickle caches cannot do: CACHE_VERSION throws the
old data away, and history is the one store where that is a loss rather than a re-fetch.

Everything else about the record is unchanged by the format:

  • sql goes through redact.redact_text() before it is written. A query can carry a
    credential — attach 'postgres://user:pw@host', create user … password '…' — and the
    product plan's §12 lists query history as a leak surface that sits outside the declaration
    mechanism. It sits outside the declaration, not outside redact: redact_text() masks
    every value this process was told to hide. Which means the IDE has to start calling
    hide_secrets_in() (§1.4) — one line in cli.py, and the debug screen gets more honest for
    free.
  • connection is the same id the caches key on, so the History screen can show this
    database's queries and --history can filter to them. get_connection_hash() moves out of
    catalog_cache into query_log (it is a hash of a dict; it never needed rich), and
    catalog_cache imports it from there.
  • status is ok, error or canceled — the third because M2 established that a
    cancelled cursor is indistinguishable from an empty result unless the caller attributes it.

Failure never fails a query. A store that cannot be opened, migrated or written disables
logging for the process — sqlite3.Error in one place, not at each call site. hsql says so
once on stderr through diagnostics (never stdout — §"stdout belongs to query output"), the
IDE notifies once. Retention replaces file rotation and is a delete rather than a
rename: once per process, rows beyond the newest 100,000 go, so nothing is ever lost from
the end that the beginning did not push out.

Opt-out is --no-write-history, a flag on both commands and so a profile key of the same
name (Ted's call). Spelled as a refusal because only one of the two values changes behavior,
and because --history is the mode below: a key called history and a mode called --history
would have been one word meaning two things. Nothing else reports the store's path: --info
describes the installation, not the user's data, and the mode below is how a caller reads the
rows without needing to know where they are.

JSON Lines does not go away; it stops being the storage. hsql --history --jsonl emits
exactly the record above, one object per line, because the mode is a result set and every
format already works on it (§3.3) — so the "readable with ordinary tools" property the product
plan asked for now works on Windows and needs no jq. And the store is a SQLite file, so the
other ordinary tool is Harlequin: harlequin -a sqlite ~/.local/state/harlequin/history.db
opens your own query history in the IDE, with a catalog and a results viewer around it.

3.3 `hsql --history` and `--history-search`, the modes that read the store

Two mode options, shaped like the two M2 built — --catalog lists, --catalog-search finds —
and they cost almost nothing to build, because the whole output layer is already written and
these two are not listings at all: they are queries. --catalog has to synthesize a result
set, since what it is reporting on is a walk over catalog objects, and that is what
query.rows_to_result() exists for. --history is running SQL against a SQLite database, so
its rows go where a cursor's rows go: create_backend(rows, column_names=COLUMNS), the same
call query.fetch() makes for every query and the same one harlequin_sqlite makes with its
own fetchall(). What comes back is the ResultSet the query path builds, so --format json, --jsonl, --csv, -x, -o PATH, --stats and the layout flags all work on it the
day it lands, and none of them is a line of new code.

Through stdlib sqlite3, not through the SQLite adapter: -P prod names the adapter whose
connection_id filters the store, and loading a second adapter plug-in to read a file this
module already owns would be a strange way to answer a question about postgres.

bash
hsql --history --limit 20                 # the last 20, as a table
hsql --history -P prod --json             # this profile's, for an agent
hsql --history-search "line_items"        # every query that mentions it, newest first
hsql --history -x --limit 1               # the last one, vertically
  • The only database they open is the store. They are the only modes that read a profile
    and build an adapter without ever connecting with it: the adapter is constructed solely to
    ask it for connection_id, which is what filters the store to one database. A history mode
    that woke a warehouse to list queries would be its own kind of joke.
  • Scope follows the invocation. -P, -a or a CONN_STR narrows to that connection;
    with none of them, the mode reports every connection, because "I typed hsql --history and
    got someone else's idea of the default database" is the surprising answer. The profile and
    adapter columns are what tell them apart.
  • Newest first, which is what every shell means by history, and what makes --limit mean
    "the most recent N" rather than "the oldest N". --limit is honored here rather than refused
    as it is under --catalog: the store has a row count, so a hard limit on it is exact.
    Default 500 — the size of the deque the History screen has always kept — and -1 for all.
  • Columns: run_at, program, profile, adapter, status, rows, elapsed_ms,
    sql.
  • sql is folded to one line, in every format. Two invariants force it and one fact
    makes it harmless: layout.py pads by terminal cells and has no concept of a cell that
    spans rows, and --format table and --format csv agree cell for cell, pinned by the
    golden-format snapshots (M1 §5) — so a column that is verbatim in JSON and folded in a
    table is not on offer. Folding loses formatting, not meaning: " ".join(sql.split()) is
    still the query, and still runnable.
  • --history-search TERM is its own mode, exactly as --catalog-search is its own mode
    beside --catalog: same store, same columns, same --limit, one added sql like '%TERM%'.
    M2 cut --find and settled on --catalog-search, so a caller who learned that spelling
    guesses this one, and _one_mode() already refuses two modes in one invocation. It is
    #429's headless half, it is one clause
    rather than a reader, and it is the flag that would have been unaffordable over a file
    (§3.2). Unlike --catalog-search it declares nothing and refuses nobody: no adapter
    capability is involved, because nothing is asked of the database.
  • The read is one indexed query, where connection = ? [and sql like ?] order by id desc limit ?. hsql --history --limit 20 costs the same on a 100,000-row store as on a 20-row
    one, and the reverse-scan reader an append-only file would have needed — with its truncated
    last line, its malformed record, and its "how far back do I read for 500 matches" — does not
    have to exist.
  • The columns keep their types, and that costs a duckdb import. create_backend() infers
    from the tuples: rows arrives int64, elapsed_ms double, the rest strings. So
    --json emits "rows":42,"elapsed_ms":12.5 rather than "rows":"42", which is the
    difference between a record an agent can sum and one it has to re-parse. The price is that
    text_columns() no longer short-circuits on an all-string table, so the text layouts cast
    through duckdb: +112ms to import it (pyarrow alone is 61.6ms; pyarrow and duckdb
    together, 173.7ms) and 1.4ms to cast 20 rows, 1.8ms to cast 500. Worth it (Ted's call), and
    the rendering does not change either way — duckdb prints 12.5 where str() would have.

These are the modes the skill teaches for B7 — an agent joining a task mid-stream reads what
the human has been running, or searches it for the table it cares about, in the format it
wants, on Windows as well as anywhere else, with no jq and no path to know.

3.4 The History screen reads the store, and gains a filter

History stops being a pickled deque and becomes a query: History.recent(connection=…, n=500, search=None), the same order by id desc limit ? the two modes run. QueryExecution keeps
its __rich__; it just gets its rows from the store rather than from CatalogCache.

Three things follow, and they are the point of the milestone:

  • A crashed session keeps its history, because the row was written when the query ran.
  • hsql's queries appear in the human's History screen, which is B7 — "as an agent
    joining a task mid-stream, I read the human's recent query history" — working in both
    directions from one store.
  • CatalogCache.history is removed and CACHE_VERSION goes to 3. A one-time migration
    runs when the old cache is present and the store holds nothing for that connection: the
    pickled records are inserted with their own timestamps and program = 'harlequin'. Fifteen
    lines, and it is also the test that proves the writer accepts historical timestamps.

The filter is #429, and it is an Input over the same query. Typing narrows the list to
rows whose sql matches, over the whole store rather than the 500 the screen holds — which is
the point, since the query you are hunting for is usually not in the last 500. The query runs
on a worker thread, debounced the way the editor debounces its symbol scan, and 35ms over
100,000 rows (§3.2) is what makes that feel like typing rather than waiting. Escape clears the
filter; Enter still inserts the highlighted query into a new buffer, as it does today.

The screen is reworked in the same release (#850, §1.2): a ModalScreen, so the app's
bindings stop reaching through it; a preview that is a highlighted, non-focusable surface
rather than a read-only editor with a cursor in it; and the screen's own bindings shown the way
every other screen shows them — which the filter makes more pressing rather than less, since a
screen with a text input in it is a screen where every stray binding is a surprise. Textual
8.2.8 has no textual.highlights, so the preview stays a TextEditor that cannot take focus
rather than becoming a Static — a swap to make when the version that offers it is pinned, not
before.

3.5 "Copy CLI command", and the module both directions share

harlequin.invocation holds what a command line said:

python
@dataclass(frozen=True)
class Invocation:
    adapter: str | None  # only when it was typed
    conn_str: tuple[str, ...]
    profile: str | None
    config_path: Path | None
    read_only: bool
    adapter_options: Mapping[str, Any]  # typed, not defaulted, not from the profile

    def to_hsql_argv(self, sql: str, *, limit: int | None) -> list[str]: ...
    def to_harlequin_argv(self, open_paths: Sequence[Path]) -> list[str]: ...

Both commands build one — harlequin/cli.py from its own explicitly_set set, hsql/cli.py
from its own — and each renders the other command's argv. §3.1's handoff is
to_harlequin_argv(); the TUI action is to_hsql_argv(). One module, two pure functions, no
subprocess and no widgets in the test.

The action, copy_cli_command, renders the queries that would run —
_get_selected_queries(), the same call Ctrl+Enter makes, so the copied command is what the
human is looking at — and the Run Query Bar's limit when the box is checked:

text
hsql -P prod -c 'select * from orders where day = current_date' --limit 500

Quoting is shlex.quote on POSIX and subprocess.list2cmdline on Windows. It goes through
_copy_to_clipboard(), so it also goes out over OSC 52 and works across ssh. No format
flag
: table is the default and is what the human is looking at; an agent handed the string
adds --csv because the skill tells it to (§6.9). A secret in a typed adapter option is
masked rather than copied, with a notification saying so — the same rule as §3.1, for the same
reason, since a clipboard outlives the app too.

Shipped unbound: every obvious key is taken, and harlequin --keys plus the docs are how
a user picks one. The action is in the registry, so it is bindable and listed the day it
lands.

3.6 The external editor (#1102)

The small one, and it can ship first because it needs nothing else in this milestone.

text
capture the buffer  ->  temp file  ->  suspend the app  ->  run the editor  ->
read the file back  ->  one undoable edit  ->  delete the temp file
  • Resolution order: config editor, then $VISUAL, then $EDITOR, then refuse. No
    built-in fallback to vi or notepad: launching an editor the user never named is the one
    outcome nobody asked for. The refusal names both spellings.
  • $EDITOR and $VISUAL need no confirmation. The environment is the user's own shell
    session (Ted's call, and the reason). A config-defined editor is a config-defined shell
    command and goes through §3.8 like any other.
  • The exchange is a file, because editors do not read stdin. A
    NamedTemporaryFile(suffix=".sql") written from the active buffer, handed to the editor as
    its last argument — the git rebase -i shape that #767 identified and that Posting
    implements.
  • with app.suspend():, on the main thread. Wrapped so SuspendNotSupported and OSError
    become an error modal rather than a traceback, and behind
    harlequin.external.run_in_terminal(app, argv) — one seam, because §1.6 measured that no
    test can enter the real thing.
  • A non-zero exit discards the edit, with a notification naming the status. That is the
    git commit/:cq convention, and it is the only escape hatch a user has once the editor is
    open.
  • The result is applied by assigning CodeEditor.text, which in textual-textarea 0.18.1
    checkpoints undo history and calls replace() over the whole document — so the round trip
    is one Ctrl+Z away, and the buffer's selection and scroll are the editor's to restore.

The action is code_editor.edit_externally, on CodeEditor, and #1102's third checkbox —
"post-external-editor action", e.g. format or run on return — is not in M4 (§7): the
buffer is back in the editor and the existing keys already do those things.

3.7 The command hook (#952)

The other shape: a filter, not a takeover.

toml
[commands.ai]
command = "claude -p 'Rewrite this SQL. Output SQL only, no code fences.'"
description = "Rewrite with Claude"     # what --keys and the footer call it
stdin = "buffer"                        # buffer | selection | none
output = "replace"                      # replace | insert | new-buffer | none
timeout = 120
  • It runs on a worker thread — @work(thread=True, exclusive=True, group="external_commands", exit_on_error=False) — so the app stays alive, the catalog keeps loading, and a query the
    user already started keeps running. Nothing in it touches a widget: it posts
    ExternalCommandFinished(name, stdout, stderr, code) and an @on handler applies the
    result, exactly as the query workers do.
  • stdin in, stdout out. The buffer (or the selection) goes in; stdout replaces the buffer
    as one undoable edit, or is inserted at the cursor, or opens a new buffer. Empty stdout is
    never applied
    : a model that returned nothing must not blank a human's query. Non-zero exit
    → error modal carrying stderr, buffer untouched. stderr on a successful run → a
    notification, because plenty of tools log there.
  • The context is a profile name, not a payload. The child is handed HARLEQUIN_PROFILE,
    HARLEQUIN_ADAPTER and HARLEQUIN_CONN_STR in its environment, and that is the whole of
    the "and optionally the catalog" the product plan wanted: a hook that needs the schema runs
    hsql -P "$HARLEQUIN_PROFILE" --catalog or --catalog-search, which is the command M2 built
    and M3 documented, against the same profile, with the same redaction. Marshalling a catalog
    onto stdin would be a second, worse copy of a thing this project already ships — and it
    would pay for a walk the user may not need.
  • timeout bounds it (default 120s): terminate(), two seconds of grace, kill(). While
    a hook is running the Cancel Query binding stops it too — to a user, "stop" is one idea, and
    a second cancel key for a second kind of work is a worse answer than one key that stops
    whatever is running.
  • No shell = true. A pipeline goes in a script and the script goes in command; a shell
    string would mean two quoting rules (POSIX and cmd.exe) for a feature whose whole value is
    running something the user already has. command takes a list, or a string that shlex
    splits.
  • hsql runs no hooks, ever. A headless command that executes shell commands out of a
    config file it found in $CWD is a supply-chain hazard in CI. hsql reads the table for
    --config validate and ignores it otherwise.

3.8 Trust: what a config file may make Harlequin execute

Two rules, and neither is a preference the config file can set for itself.

Rule 1 — provenance. [commands] and a top-level editor are honored only from a config
file the user named or keeps: an explicit --config-path, the user config dir, or home. A
definition found in ./.harlequin.toml or ./pyproject.toml is ignored, with one
notification naming the file and the key. Merging already tracks which file supplied each key
(Provenance, M2 §3.6), so this is a check at the point of use, not a second pass.

Rule 2 — consent, per definition. Before a command runs for the first time, a modal shows
the exact argv, the config file it came from, and three choices — Run once / Always allow /
Cancel — with Cancel focused. "Always allow" appends a record to
/harlequin/trusted_commands.json:

json
{"name":"ai","source":"/home/ted/.config/harlequin/config.toml",
 "command":"claude -p 'Rewrite this SQL…'","fingerprint":"sha256:…","trusted_at":"2026-09-02T…"}

The fingerprint covers the name, the resolved argv and the source path, so editing the
command re-prompts
. The store is in state, not in config, deliberately: a synced config file
that could carry its own approval would defeat the gate it is supposed to pass. The plaintext
command sits beside the hash so the file can be audited by the person it protects, and revoking
is deleting a line.

What is not gated: $EDITOR/$VISUAL (§3.6), and hsql --open launching harlequin
(a known entry point in the same interpreter, not a user string).

What is never automatic: a hook runs only from a key the user pressed. No hook on start-up,
on connect, on error, or on quit — that is how a config-defined command becomes a startup
payload, and there is no version of it M4 wants.

3.9 Actions can come from config, so the registry becomes a function

HARLEQUIN_ACTIONS stays as the static base; build_actions(command_names) returns it plus
one command.<name> entry per configured hook, targeting CodeEditor with
run_command('<name>') and the hook's description. app.py, keys_app.py and the keymap
loader all take the built registry — keys_app already loads config to find keymaps, so it
can list a user's own commands in the binding editor beside the built-in actions.

And the crash in §1.5 gets fixed on the way, because it is now reachable by typo and by a
command that was renamed: an unknown action name is skipped with one notification naming the
keymap and the action
, not a KeyError in mount. A keymap with one bad line should cost the
user that line, not the application.


4. Sequencing

Three releases, and the editor and the hooks go first (Ted's call). Nothing in this
milestone depends on anything else in it — that is what makes the order a choice rather than a
constraint — so the order is by what users are waiting for and by how much design risk each
group carries. #1102 is open, has a
reference implementation, and needs nothing else here; the trust model under it is the most
consequential decision in M4 and benefits from being in the world early rather than shipping
last with two other features on top of it. The query history follows, because it changes a
stored format and a screen, because it is what makes an agent's queries visible to a human at
all, and because search (#429) is what
decides the format it changes to. The handoff is last: it is the largest new public surface —
two flags across two commands — and it lands better after the history, since a draft handed to
a human and the queries they run against it are then one story rather than two.

Each release maps to a run of §3: A is §3.6–§3.9, B is §3.2–§3.4, C is §3.1 and §3.5.

Numbering assumes M3's remaining work releases as 2.13.

Release A — the editor and the hooks (2.13)

PR 1 — The external editor. $EDITOR/$VISUAL only, harlequin.external.run_in_terminal(),
the suspend wrapper and its unsupported-environment path, temp-file exchange, non-zero-exit
discard, and the code_editor.edit_externally action. No config key and no trust store yet, so
it is reviewable on its own. Closes
#1102 for the request as filed.

PR 2 — The trust gate. Provenance rule, consent modal, trust store, and the config
editor key as its first consumer. The gate ships with something small enough to review it
against, and a release before anything larger runs through it.

PR 3 — [commands] in config, and actions from config. The Config member, the schema
regeneration (scripts/write_config_schema.py, and the pinned artifact), build_actions(),
the keymap-validation fix from §1.5, and --config validate's coverage of the new table.
Nothing executes yet; the keys bind to an action that reports "not implemented" in exactly one
commit, or PR 3 and PR 4 land together if that reads badly in review.

PR 4 — Running a hook. The worker, stdin/stdout, the four output modes, the empty-output
rule, timeout and cancel, error surfacing. Answers
#952.

PR 5 — Docs (harlequin-web): "External editor" and "Bring your own AI" pages, the trust
model stated plainly, and a worked claude -p config with the prompt that keeps code fences
out of the buffer.

Release B — query history, and search (2.14)

PR 6 — harlequin.query_log. The schema, the pragmas, user_version and its migration
step, the writer, retention, get_connection_hash()'s move out of catalog_cache,
--no-write-history, and both commands writing. The IDE starts calling hide_secrets_in()
here — the store is the first thing it persists that could carry a value from a profile
(§1.4). Guards: hsql's import set still contains no rich, and hsql --version is still
~120ms.

PR 7 — hsql --history and --history-search. Both modes, the connection filter, the
folded sql column, --limit as "the most recent N", and the search clause. Nothing new in
the output layer — the listing goes through the emitters --catalog already uses. Adds both
modes to the skill. Closes B7's headless half and #429's.

PR 8 — The History screen reads the store. History.recent(), CatalogCache.history
removed, CACHE_VERSION to 3, and the one-time migration. This is the PR where an agent's
queries first show up in a human's History screen, and where the human's show up under
--history.

PR 9 — The History screen, reconsidered. The filter input over the whole store, plus
modal, a non-focusable preview, and the screen's own bindings shown as every other screen
shows them (§1.2, §3.4). Search and the rework land together because they are the same
screen and the same reading of it: the pane is for choosing a query, not for editing one.
Closes #429 and
#850.

PR 10 — Docs (harlequin-web): the query history — where it lives, what it holds, how to
search it from either front end, and that it is a SQLite file the reader can open in Harlequin
itself — and --history in "Headless & Agents".

Release C — the handoff (2.15)

PR 11 — harlequin.invocation, and harlequin --open. The dataclass, both argv renderers,
and the IDE flag that loads files into buffers without executing them. cli.py builds an
Invocation and hands it to the app. Closes the half of F1 the IDE owns, and is useful on its
own (harlequin --open report.sql).

PR 12 — hsql --open. The mode option, the secret refusal, the scratch file and its
pruning, the exec/subprocess split, and the /dev/tty reattach. Rewrites the skill's section
9 (hsql --skill ships from the wheel, so this is the same PR). Closes F1.

PR 13 — Copy CLI command. The action, the quoting, the masking, the registry entry. Closes
F2.

PR 14 — Docs (harlequin-web): the handoff section in "Headless & Agents", and --open on
both command pages.

Ordering rationale. Within a release, unchanged from M1 and M2: a contract or a gate lands
with the first consumer that needs it, and nothing declares a thing nothing reads. Across
releases, the three groups are independent, so the order is a product decision rather than a
technical one — which also means it can change again without rewriting §3.


5. Testing

Unit, and most of the milestone is unit-testable.

  • Invocation.to_hsql_argv() / to_harlequin_argv(): profile vs conn_str, typed vs defaulted
    options, the secret refusal, and quoting on both platforms (parametrized over
    shlex.quote/list2cmdline rather than skipped on one).
  • query_log: row round trip; the schema migration, from user_version = 0 forward and on a
    store already at the current version; retention trimming to the newest N; redact_text()
    applied to sql; an unwritable directory and an unreadable store each disabling logging
    without raising; and the WAL fallback, exercised by a store where journal_mode=wal does
    not stick.
  • The concurrent write measurement becomes a real test — 4 processes × 100 inserts,
    asserting every row lands and no sqlite3.Error escapes — marked so it can be deselected on
    a slow runner.
  • The pickle migration, including timestamps that predate the store, and a second run that
    does not double-insert.
  • --history: the connection filter with two connections interleaved; --limit taking from
    the newest end; --history-search matching mid-string, and its case behavior pinned where
    SQLite's like would otherwise surprise us; the folded sql column matching between
    --format table and --format csv, asserted the way the golden-format snapshots assert it
    rather than against a copy of the folding rule; --json emitting rows and elapsed_ms
    unquoted, and null for a statement that has neither; and an empty store printing a header
    and no rows rather than nothing.
  • A guard that --history opens no connection: the mode runs against a profile whose adapter
    would raise on connect(), and still prints rows.
  • Trust: fingerprint stability across a whitespace change (it should change — the argv is what
    runs), and the provenance rule (a [commands] table in ./pyproject.toml is ignored; the
    same table under --config-path is not).
  • build_actions(): a command name that collides with a built-in action, and a keymap naming
    an action nothing defines.

Functional (pilot + snapshot).

  • harlequin --open a.sql b.sql: two new buffers, the second active, nothing executed —
    assert on the absence of a QuerySubmitted, not only on the snapshot.
  • The copy action: assert the string, through the editor's clipboard rather than the system's.
  • The hook worker against a hermetic child — [sys.executable, "-c", …], never an external
    binary — covering success, empty stdout, non-zero exit with stderr, and a timeout that kills
    a child that ignores terminate().
  • The consent modal: cancel leaves the buffer alone and runs nothing; "always allow" writes one
    record and the second invocation does not prompt.
  • The History screen after #850 and #429: an app-level binding pressed on the screen does
    nothing, Tab does not land focus in the preview, and typing in the filter narrows the list
    to rows the 500-record window would not have held — all asserted on behavior, not on the
    snapshot, since the snapshot changes for reasons none of them cares about.
  • The editor round trip with run_in_terminal patched, plus the SuspendNotSupported path
    asserted as a notification — §1.6 measured that the real suspend cannot be reached in
    run_test(), so the seam is the test surface.

Import hygiene. harlequin.invocation and harlequin.query_log join HEADLESS_IMPORTS.
A run that logs must not import rich: that is the assertion that keeps §1.3's finding from
coming back as a 46ms regression on every hsql -c.

Not tested, and said out loud: no test runs a real editor or a real model; nothing asserts
on /dev/tty reattachment (CI has no controlling terminal) — the decision function is tested,
the two dup2 calls are not.


6. Decisions

6.1 The external editor and the AI hook are two features

The product plan folds them together — "same shape as the existing external-editor request" —
and the shape is where they differ:

External editor Command hook
The terminal needs it; the app suspends must not have it; the app stays live
Thread main, blocking (suspend redirects the process's streams) worker
Exchange a temp file, edited in place stdin → stdout
Duration as long as the human takes seconds, and needs a bound
Cancel quitting the editor a timeout, and a key
Failure non-zero exit means "discard" non-zero exit is an error to show
Config one key; $EDITOR is usually all of it a named command with a prompt, per task
Trust the environment is the user a file is not
Privacy the query stays on the machine the query may leave it

What they share is four lines: build an argv, start a process, read a result, apply it as one
undoable edit. Unifying them means a single config table with an interactive switch, a
{file} placeholder whose presence silently changes the exchange, and a validity table where
most combinations are refused — a worse config than two tables, to save four lines. They also
close two different issues, for two different users, and the editor ships first because it
needs no trust store at all — $EDITOR is the user's own environment (§6.8).

6.2 `hsql --open`, not `hsql open`

M2's rule, unchanged: CONN_STR is positional, so a subcommand named open would need a rule
to distinguish it from a DuckDB file named open. --open needs none, and it composes with
-c, -f and stdin, which a subcommand taking its own path argument would have duplicated.

6.3 The handoff execs; it does not import, and it does not write the buffer cache

Measured in §1.1: the cache is Textual (+164ms and 341 modules) and is rewritten whole at
quit, so a second writer races the IDE. An in-process import of harlequin.cli would break
the one contract hsql is defined by. python -m harlequin in the same interpreter is the
only option that is both correct and cheap, and it is cheap only relative to what it launches
— a 620ms harlequin --version is the floor for anything that opens the IDE.

6.4 One SQLite store, not JSONL, and not one file per connection

The roadmap says "JSONL history," and this plan said so too until #429 came into scope. The
reversal is in §3.2 and rests on one number: searching 100,000 records is 396ms as a file scan
and 35ms as a like. Everything else was close enough to be a preference — writes 14µs apart,
sqlite3 importing cheaper than json, files within 15% in size, tail reads a wash — and
search is not close.

Three second-order effects, all in the same direction. The reader stops existing: no
reverse-scan-by-chunks, no truncated last line, no "read further back until 500 match" — one
indexed query instead, and a whole class of §5 tests with it. A schema change becomes an
alter table
, where the pickle caches bump a version and discard, which is the wrong
behavior for the one store whose contents cannot be re-fetched. And the file is a database
Harlequin can open
: harlequin -a sqlite ~/.local/state/harlequin/history.db is a better
answer to "readable with ordinary tools" than jq was, on every platform.

What is given up is tail -f, and hsql --history --jsonl gives back the format if not the
follow. Still one store rather than one per connection, for the reason it always was: an agent
asking what has been run against a warehouse should not have to know a connection hash to find
a file, and ordering across databases is a order by, not a merge.

6.5 `--history` is a mode, and the store's path is not in `--info`

When the store was a JSONL file, the tempting cut was to ship it and let tail and jq be
the reader. It is a mode (Ted's call), and three things say so — the first two of which
outlived the format, and the third of which is why a SQLite store needs one even more.

A mode is nearly free, because a listing is a result set. M2 §3.3 built that road for
--catalog: hand the emitters a ResultSet and every format, every layout flag and -o
follow. --history is a reader plus a table; it is not a second output stack.

The pipeline is not portable and not self-describing. It needed jq, a path, a filter by
connection, and a shell that has tail — which Windows does not. "Read the human's recent
queries" is a thing the skill should be able to teach in one line that works everywhere. With
a SQLite store the pipeline would need sqlite3 and a schema besides, so the mode carries
more weight now, not less.

And --info is the wrong place for the path. --info reports on the installation —
versions, platform, config files, adapter capabilities. A pointer into the user's own query
history is a different kind of fact, and putting it there invites a reader to go parse the file
by hand when a mode can hand them rows. The path stays in the docs, where a person who wants to
back the file up or delete it will look.

6.6 The hook's context is a profile name, not a catalog

Harlequin already ships the best catalog-for-agents interface it has (--catalog,
--catalog-search, --info), documented in M3 and taught by the skill. A hook that gets
HARLEQUIN_PROFILE can call it; a hook that gets 40 KB of marshalled catalog on stdin pays
for a walk it may not need and gets a second, worse format for the same data.

6.7 The trust store is not the config file

If "always allow" wrote back into the config, a synced config could ship its own approval and
the gate would protect nothing. State dir, fingerprinted by argv and source, plaintext beside
the hash so the person it protects can audit it.

6.8 `$EDITOR` is trusted; a config file is not

Ted's call, and the line generalizes: the environment is the user's own session, and a config
file is a document that arrives from a directory ($CWD) or a sync (dotfiles) that the user
may not have read. That is also why the provenance rule (§3.8) is separate from the consent
prompt — one keeps a stranger's file from ever asking, the other keeps the user's own file
honest.

6.9 The copied command carries no `--csv`

The product plan's example is hsql -P prod -c '…' --csv. The human copying it is looking at
a table, and the agent receiving it knows from the skill to pick a format on purpose (§5 of
SKILL.md). A format flag in the copied string is a guess about the recipient; the profile,
the connection and the SQL are not.


7. Explicitly not in M4

  • hsql mcp — M6, unchanged.
  • Streaming, --offset, and the memory work in #875 — M5.
  • A post-editor action (#1102's third checkbox: format or run on return). The buffer is
    back in the editor and code_editor.format and code_editor.run_query are already bound.
  • Hooks anywhere but the Query Editor. A hook over a result set — "explain this table" —
    is a real idea and a different feature: it needs a serialization of the result, a size
    policy, and an answer for where the output goes.
  • Code-fence stripping on hook output. The prompt is the config author's, and the docs give
    one that works. A heuristic here would be Harlequin guessing at model output.
  • shell = true, and placeholders beyond none. §3.7.
  • Action-name validation in hsql --config validate. It would be genuinely useful — an
    agent editing a keymap would learn about a typo without starting a TUI — but the registry
    lives in harlequin.actions, which imports harlequin.components and therefore Textual.
    Doing it properly means splitting the names into a leaf module and leaving the widget
    targets behind, which is a refactor to make on purpose, with the second consumer in hand.
  • A --open that starts a query running. It would collapse the review step F1 exists for.
  • --history --since 2h. Obvious the moment the mode exists, and one more where clause
    once the store is a database — but --limit and newest-first cover the case this milestone
    is for. Worth doing next, not now.
  • FTS5 over the history. like is 35ms over 100,000 rows (§3.2), an FTS table is a second
    thing to keep in step with the first, tokenizing SQL is a poor fit for a tokenizer built for
    prose, and FTS5 is a compile-time option that is usually present. Revisit at a million
    rows, with a measurement rather than an instinct.
  • Anything else in the history store. The catalog and buffer caches are still pickles, and
    the database that now exists is an obvious home for them. It is also a separate change, with
    a separate migration and a separate failure mode, and this milestone has enough of both.
  • Per-hook cancel keys, hook chaining, and hooks that write config. Each is a feature
    request that will read as obvious once one hook works, and none of them is M4's.

8. Corrections to the product plan

Recorded here; applied to harlequin-for-agents.md in the same PR as this document.

  • §11's M4 row still lists the skill, which shipped in M3 (hsql --skill, the packaged
    SKILL.md, and the marketplace entry). The row is hsql --open, the query log, "Copy CLI
    command", the external editor, and command hooks.
  • §10's hsql open query.sql is a subcommand, and M2 settled on mode options. The spelling
    is hsql --open, reading its SQL from -c, -f or stdin like every other run (§6.2).
  • §10's "buffers already persist through editor_cache.py, so the machinery is close at
    hand" is measurably wrong in the direction that matters.
    That cache imports Textual and is
    rewritten whole at quit; it is the one mechanism the handoff cannot use (§1.1).
  • §10 treats the external editor and the AI hook as one feature. They differ in the
    terminal, the thread, the exchange, the failure mode and the trust model, and #1102 now
    exists as its own issue with its own reference implementation (§6.1).
  • §10 does not mention consent, and a config-defined shell command needs it. Config files
    merge from $CWD, so a cloned repository can define one; a synced dotfiles repo can do the
    same in the user's own config dir. §3.8 is the missing paragraph.
  • The roadmap's "JSONL history" names a format that search rules out. M4 stores query
    history in SQLite and emits JSON Lines: hsql --history --jsonl is the record the roadmap
    described, and the store behind it is what makes #429 affordable (§3.2, §6.4).
  • §6's hsql history --json -n 20 is the right feature under two wrong assumptions. The
    spelling is hsql --history --json --limit 20 — a mode option, and --limit rather than a
    new -n, because both commands already mean "the rows that leave the store" by it. And what
    it reads is not the pickle §6 proposes to "expose": that one is written once, at quit, behind
    a module headless code may not import (§1.2, §1.3). The log is the store; the mode is the
    reader (§3.3).
  • §12's risk list says query history "sits outside that mechanism" and needs its own
    handling.
    It needs one line of handling: redact_text() on the way into the log, plus the
    hide_secrets_in() call the IDE has never made (§1.4, §3.2).
  • §9's skill section 9 is rewritten by this milestone, as M3 §7 predicted: the handoff
    advice becomes hsql --open -f draft.sql -P prod rather than "tell them to run harlequin".
  • #952 is closed as not planned, with "I won't be adding this to Harlequin. But shortly I
    am releasing hsql." §10's "#952 answered without embedding an LLM" is still the right
    answer; the changelog entry should reference #952 for the hook and #1102 for the editor,
    and #952 can be reopened or left closed with a comment pointing at the hook.
  • §13's success criteria should gain one line: a query run by hsql appears in the human's
    History screen, and a draft written by an agent opens in the human's editor without being
    run. That is what Workstream F is for, and neither is measurable today.

Plans Ssh Tunnels Design (`docs/plans/ssh-tunnels-design.md`)

SSH tunnels — a design for [#545](https://github.com/tconbeer/harlequin/issues/545)

Reach a database through an SSH host, from either command, with any adapter — including
out-of-tree ones that are never changed.
Nothing here is implemented yet.

Summary

  • Harlequin runs ssh; it does not touch connection details. The user's details already
    name the local end of a forward. Core never learns which adapter option is the host, never
    parses a DSN, and adds nothing to the adapter API — which is why every adapter works,
    including ones nobody in this org maintains.
  • Four flags, all --ssh-*, none of them parsed. --ssh-host and --ssh-forward go to
    ssh verbatim. Anything they cannot say goes in a Host block, which --ssh-host names.
  • No new dependencies — the blocker on the issue since 2025 — and the user's whole
    ~/.ssh/config comes for free: LocalForward, ProxyJump, agent, certificates, Match.
  • The tunnel starts before Textual, so a passphrase or 2FA prompt reaches a human — or
    --ssh-batch-mode refuses to prompt at all, for scripts and agents. It is a child process,
    not ssh -f, so it dies with the session instead of outliving it.
  • It stays up, and comes back if it drops (§5.4): keepalives when the user's config sets
    none, and a lazy restart-and-reconnect in the IDE on the next thing that needs the database.

1. Ten seconds of SSH

-L 15439:redshift.internal:5439 local forward. Listen on 127.0.0.1:15439; deliver to redshift.internal:5439 as resolved by the SSH host.
LocalForward 15439 redshift.internal:5439 the same directive, in a ~/.ssh/config Host block
-N run no remote command; I am here for the forwards
-f fork to the background once the forwards are up
-o ExitOnForwardFailure=yes exit if a forward cannot be set up, rather than connecting anyway
-o BatchMode=yes never prompt for a passphrase, password or host key; fail instead
ssh -G host print the resolved config — Host/Match blocks plus this command line's flags — and connect to nothing

ssh -fN redshift_prod and ssh -L … are not alternatives. -f and -N say how to run;
-L and LocalForward say what to forward, and they are one directive in two places. A
user who already tunnels has the forward in their ssh config
, so --ssh-forward is optional
and the motivating case (§3.1) never uses it.

2. The contract

The connection details name the local end of the forward. Harlequin runs the tunnel and
touches nothing else. conn_str and every adapter option reach the adapter exactly as typed;
the adapter is never told a tunnel exists.

text
ssh_host = "redshift_prod"   ─►  ssh -N -o ExitOnForwardFailure=yes redshift_prod
                                    (the -L comes from ~/.ssh/config)

   host = "localhost"           ─►  adapter dials 127.0.0.1:15439, the local end of
   port = 15439                     `LocalForward 15439 …:5439`

Consequences worth naming:

  • Nothing is rewritten, so connection_id and the catalog cache key stay stable across
    runs, and TLS still verifies the hostname the user asked for.
  • DuckDB and SQLite need no special case. They ignore the tunnel; a duckdb ATTACH of
    postgres://localhost:15439/prod goes through it like anything else.
  • A profile with host = "localhost" is only correct with its tunnel up. Run it without
    ssh_host and it reaches whatever is on that port locally. Recommend a distinct local port
    — 15439, not 5439 — so that mistake is a connection refused rather than the wrong
    database. The ssh_* keys sit in the same profile, so they travel together.

3. Examples

3.1 The motivating case: a real `Host` block

text
Host redshift_prod
  HostName web-1
  User tco
  LocalForward 15439 data-analytics.<aws-acct>.us-east-1.redshift.amazonaws.com:5439
  ServerAliveInterval 60
  ServerAliveCountMax 3

Today: ssh -fN redshift_prod, then harlequin --host localhost --port 15439 …, then
remember to kill the ssh.

toml
[profiles.redshift]
adapter = "postgres"
host = "localhost"
port = 15439
dbname = "prod"
ssh_host = "redshift_prod"

harlequin -P redshift, hsql -P redshift -c "select 1". One key. The connection details
are the ones that already work. Local 15439, remote 5439 — the profile names the local
end, which is all the contract says.

3.2 The same cluster, no `~/.ssh/config`

bash
harlequin -a postgres --host localhost --port 15439 --dbname prod --user tco \
  --ssh-host tco@web-1 \
  --ssh-forward 15439:data-analytics.<aws-acct>.us-east-1.redshift.amazonaws.com:5439 \
  --ssh-batch-mode

HostName + User → --ssh-host tco@web-1. LocalForward → --ssh-forward, the same text
with the space turned into a colon. (--user is Redshift's user; tco@ is the SSH host's.)
The keepalives have no flag — see §8. This is the shape for CI or an agent with no
dotfiles; everyone else writes the Host block.

3.3 Others

database on the SSH host --ssh-forward 5432:localhost:5432 — localhost is the far side's
local 5432 already taken --ssh-forward 15432:db.internal:5432, details say 15432
two databases, one host --ssh-forward twice; one child, two -L
a jump host in front ProxyJump in the Host block. Chains work because ssh does them
Cloud SQL, kubectl, SSM run the proxy yourself and point at its local port — that already works, because of §2. Harlequin just does not own its lifetime (§8)
not tunneled no ssh_host, no harlequin.ssh import, no cost

4. The options

Both commands, since one profile serves both and hsql is where it matters most: a cron job
cannot ask a human to run ssh -fN first.

option profile key meaning
--ssh-host TEXT ssh_host the destination, verbatim to ssh: a Host alias, host, user@host, or ssh://user@host:port
--ssh-forward TEXT ssh_forward repeatable; whatever follows ssh -L, verbatim. Omit when ssh_config has it
--ssh-batch-mode ssh_batch_mode ssh's BatchMode: fail rather than ask for a passphrase, password or host key (§5.2)
--ssh-allow-reuse ssh_allow_reuse on a bind collision, warn and connect anyway instead of failing (§5.3)
--ssh-timeout FLOAT ssh_timeout seconds to wait for the forwards (default 10)

No short declarations — -o is --output in both commands. These join the reserved spellings
in first_pass.attach_adapter_options(); no published adapter appears to claim one.

Harlequin parses none of these values. ssh owns its own syntax and error messages, so
there is no Harlequin-shaped subset of either to document or to get wrong. The only parsing in
this feature is of ssh -G's output (§5.1) — something ssh prints, not something a user
typed.

--ssh-host with no forward anywhere is a usage error naming both places to put one.

The start-up notice, on hsql's stderr (never stdout) and as an IDE notification and
debug-screen line:

text
ssh: 127.0.0.1:15439 -> data-analytics.<aws-acct>.us-east-1.redshift.amazonaws.com:5439 via redshift_prod

It is what tells a user which database they are actually looking at.

5. Behavior

harlequin/ssh.py, one class:

python
class SshTunnel:
    """An `ssh` child process holding one or more local forwards open."""

    def start(self) -> None:
        """Start it and block until the forwarded ports accept connections.
        Raises HarlequinSshError, quoting ssh's stderr."""

    def stop(self) -> None: ...
    def __enter__(self) -> SshTunnel: ...
    def __exit__(self, *exc: Any) -> None:
        self.stop()
text
first_pass -> click parses -> profile merge -> SshTunnel.start()   <-- prompts happen HERE
                                                    |
                                       adapter_cls(...) -> tui.run() / hsql runs
                                                    |
                                             ExitStack unwinds -> stop()
  • Before Textual, in the click callback. Once Textual owns the terminal, ssh cannot ask
    for a passphrase and a 2FA push has nowhere to print "check your phone". It also gives both
    commands one error path — pretty_print_error / diagnostics.report_error, exit code 3.
  • Foreground, not -f. The child keeps the terminal, so prompts work, and we keep the
    handle, so it can be killed. Teardown is terminate() then kill() from a
    contextlib.ExitStack around tui.run() and the hsql run, plus an atexit backstop.
  • ExitOnForwardFailure=yes is the only -o Harlequin imposes on its own, because a forward that
    silently did not happen is the one failure a user cannot diagnose. Notably not imposed:
    ServerAliveInterval. A command-line -o beats the config file, and §3.1 already sets
    keepalives — overriding them would be Harlequin retuning someone else's connection.
  • The IDE says when the tunnel dies. A thread waiting on the child posts
    tunnel closed: <ssh's last line>, so a dropped forward is not an unexplained wall of query
    errors. No reconnect in v1. hsql is short-lived; the adapter's error is the whole story.

5.1 `ssh -G` says what is about to be forwarded

text
$ ssh -G redshift_prod | grep -i forward
localforward 15439 [data-analytics.<aws-acct>.us-east-1.redshift.amazonaws.com]:5439

Probe with the argv we are about to run. ssh -G echoes command-line -L flags back
along with the config's, so one call returns one list whether the forward came from
--ssh-forward, a Host block, or both — there are not two sources to keep in agreement.

Parsing is two whitespace-separated fields after the keyword, each port, [host]:port, or a
socket path (which counts as a forward but is not polled). The same output answers one more
question — serveraliveinterval, for §5.4. It costs one subprocess, ~10–30ms, no network. If
-G fails or prints something unparseable, Harlequin degrades: no poll, no forwards-nothing
error, no imposed keepalive, a short grace period, and the adapter's connection is the test.

Why this step exists at all: it is the only way to know which local port to wait on before
the adapter connects, and in §3.1 — the motivating case — Harlequin never saw a port number.
ssh -f would signal readiness by exiting, but the surviving process would not be our child
and could not be killed at exit, which is the orphaned tunnel this feature removes.

5.2 Readiness, and prompts

Connect-poll each local port -G reported until --ssh-timeout (default 10s), noticing the
child exiting early. On failure, the error quotes ssh's stderr verbatim — its diagnostics are
better than any we would write.

A prompt nobody answers is what the timeout catches, and catching it late is not good
enough for a script: ssh asks for a passphrase, a password, or confirmation of an unknown
host key, and an unattended caller waits out the whole timeout and then reports something
vague. --ssh-batch-mode passes -o BatchMode=yes — ssh's own name for it, so anyone who
knows the ssh_config keyword knows the flag — and ssh fails immediately, saying which
credential it wanted. An agent, a cron job and CI should all set it.

Without the flag the timeout is still the backstop, so nothing hangs forever — and when it
fires with the child still running, the error names the flag, because waiting on input is the
likeliest reason a live ssh never opened its forwards.

5.3 A local port that is already bound

Harlequin always tries to start the tunnel; ExitOnForwardFailure=yes means ssh exits
rather than connecting without the forward. What happens next is the flag:

default fail and exit 3, quoting ssh (bind: Address already in use)
--ssh-allow-reuse if every forwarded port is now accepting connections, warn and connect anyway
text
ssh: 127.0.0.1:15439 is already bound; connecting through the existing listener (--ssh-allow-reuse)

The reuse test is behavioral — the child failed, but the ports answer — rather than a match
against ssh's message, so it does not depend on wording. If only some ports answer, that is a
half-open state nobody meant: fail either way.

Failing by default is the safe direction: a port that answers is not proof the right tunnel is
behind it. --ssh-allow-reuse is for the person who keeps ssh -fN redshift_prod running all
day and does not want Harlequin fighting it.

5.4 Keeping it open, and getting it back

A tunnel that drops after lunch is the difference between a feature people trust and one they
work around. Three layers, cheapest first.

Keep it alive. ssh sends no keepalives by default, so an idle forward behind a NAT or a
corporate firewall is reaped silently. The -G probe already reports the resolved
serveraliveinterval: if it is 0, Harlequin passes -o ServerAliveInterval=30; if the user
set one — §3.1 sets 60 — it is left alone. No flag, no override, and the common case stops
dropping. Not configurable: a keepalive interval is not a thing anyone should have to tune,
and the one person who wants to has a Host block.

Say so immediately. A thread waiting on the child posts tunnel closed: <ssh's last line>
when it exits unexpectedly, so a dead forward is not an unexplained wall of query errors.

Recover on next use. Restarting ssh is not enough on its own: the adapter's TCP
connection ran through the old forward, so it died with it. Recovery is both halves,
together, and it happens lazily — before the next thing that needs the database (a query, a
catalog refresh), not on a background timer:

  1. the tunnel is marked down when the monitor sees the child exit;
  2. the next database action restarts ssh and re-runs the readiness poll;
  3. then re-runs adapter.connect(), replacing self.connection;
  4. then does what the user asked.

Three things this has to get right:

  • Restarts are always BatchMode=yes, whatever the flag said at start-up. Textual owns
    the terminal now, so a passphrase prompt has nowhere to go. If the restart needs a
    credential we cannot supply, it fails once with that message and does not retry — the user
    restarts Harlequin, having been told why.
  • Say what was lost. A reconnect is a new session: open transactions are gone, so are temp
    tables and anything set with SET. The notification says so rather than letting a user
    believe they are still inside a BEGIN.
  • One attempt per action, no loop. The user retries by running their query again. A
    background retry storm against a bastion that is down is how an account gets locked.

hsql does none of this. It is short-lived, its statements are not idempotent, and an
unattended caller that loses its connection should fail loudly: the statement errors, and the
run exits 3 (or continues, per --on-error). Only the keepalive applies to both commands.

Reconnecting the adapter is useful beyond tunnels — a server restart or an idle timeout kills
a connection the same way — but v1 triggers it only from a tunnel that went down, because
that is the case it can detect without guessing.

6. What this touches

No public API change. Nothing in HarlequinAdapter, HarlequinConnection,
HarlequinCursor, AbstractOption, catalog.py or driver.py. One new
HarlequinSshError(HarlequinError), one new module, and — for §5.4 only — a reconnect path in
app.py that re-runs adapter.connect() on the existing connect worker.

No dependency: subprocess, socket. harlequin/ssh.py imports no Textual and no
adapter, joins the "adapter API is reachable without the TUI" contract, and is imported only
when ssh_host is set — scripts/cold_start.py should show no change.

One cache-key change. Two bastions fronting two databases both look like
localhost:15439, and would share a catalog cache and query history. The ssh destination and
the resolved forwards join the hashed material.

Config, for free. A command's click params claim its profile keys, so ssh_* become valid
profile and JSON-schema keys with no further plumbing; regenerate schemas/config-v1.json.

7. Testing, and phasing

No SSH server, no online marker.

  • Lifecycle, all three OSes. SshTunnel takes an argv and ports, so tests hand it a
    Python child: a ~30-line loopback forwarder in tests/, covering start, poll, timeout,
    reuse, half-open, the death notification, and teardown.
  • End to end, Unix. A fake ssh on PATH that understands -G and -L exercises the
    real CLI path including the probe. Skipped on Windows.
  • Unit: --ssh-forward reaches argv unchanged and repeats in order; --ssh-host is
    unparsed; -G lines with a bind address, IPv6 and a socket path parse; -G returning only
    dynamicforward is a usage error; -G garbage degrades; no forward anywhere is a usage
    error; the argv carries ExitOnForwardFailure and, with --ssh-batch-mode, BatchMode=yes and
    nothing else; -o still means --output; a timeout with the child alive names
    --ssh-batch-mode; profile round trip; two ssh hosts hash differently.
  • Recovery: -G reporting serveraliveinterval 0 adds -o ServerAliveInterval=30, a
    non-zero one does not; killing the child marks the tunnel down and notifies; the next action
    restarts it in batch mode, reconnects, and warns about lost session state; a restart that
    fails does not retry.
  • One test runs ssh -V and skips if absent, proving the argv is accepted by a real client.

Phasing: (1) harlequin/ssh.py — argv, -G probe and parser, poll, reuse,
HarlequinSshError; unit and lifecycle tests, no CLI. (2) the four options, the
ExitStack, the notice, the cache key, the end-to-end test. (3) the keepalive default, the death
notification, --info and debug-screen reporting, regenerated schema. (4) lazy
restart-and-reconnect in the IDE (§5.4), which is the one step that touches app.py. (5) docs in harlequin-web — the
contract, and the Host block as the recommended setup — plus a CHANGELOG.md entry under
[Unreleased] → Features citing #545.

8. Rejected and deferred

Rejected.

Rewriting the adapter's host/port (the issue's sketch, and this doc's first draft) Core would have to know which option is the host: a role= declaration no adapter has, a name-matching guess, and a DSN rewrite — three mechanisms that can be quietly wrong, to reach where a local forward reaches with none. It also bakes an ephemeral port into connection_id and breaks TLS hostname verification unasked.
--ssh-option KEY=VALUE -o ProxyCommand=… is arbitrary code execution, and config files are discovered in the working directory — a cloned repo's pyproject.toml must not be able to run a command. A deny-list would mostly work, but KnownHostsCommand arrived in OpenSSH 8.5, so it is a list to revisit forever and a miss is a hole. Four flags that are a hostname, a forward spec, a boolean and a number have nothing to deny. A ProxyCommand in the user's own ~/.ssh/config is unaffected and always was.
--ssh-config PATH (ssh -F) Looks safer — a path, not a keyword — but the file it names can hold a ProxyCommand. Same threat, extra step, no deny-list possible.
--ssh-user / --ssh-port / --ssh-identity ssh_config spelled a second time.
A configurable local bind address ssh_config and the forward spec already do it; 0.0.0.0 should take deliberate effort.
require_tunnel = true A distinct local port already turns the mistake into a connection refused.
A {tunnel_port} placeholder in the conn_str General, but a second thing to learn for the same result, and it makes the profile unusable without the tunnel.
SOCKS (ssh -D) with socket.socket patched Keeps the real hostname, so TLS is untouched — but only works for pure-Python drivers. psycopg2, mysqlclient, ODBC and duckdb open sockets in C.
Per-adapter --ssh-* N implementations, N spellings, and the adapters that need it most are out-of-tree.
A library adapters call Opt-in, so "any adapter" becomes "adapters that adopted it". Fine in addition, for something like the Cloud SQL connector.
sshtunnel Four years without a release, wrapping paramiko for behavior a binary on the machine already has.
paramiko in v1 A cryptography dependency on every install, reimplementing the ~/.ssh/config handling users already have working.
"Just run ssh -fN yourself" Works today, but means a second terminal, an orphaned tunnel to kill, and a profile that only connects if a human ran something first — which hsql in a cron job cannot do.

Deferred, and what brings each back.

--ssh-keepalive SECONDS someone runs an all-day IDE session with no ~/.ssh/config and gets dropped (§3.2)
--tunnel-command for non-SSH proxies a Cloud SQL or kubectl user wants Harlequin to own the proxy's lifetime, with an answer to the code-execution problem above
a paramiko backend, harlequin[ssh] a user on Windows or in a container with no ssh binary
reconnecting a database connection that dropped with the tunnel up a server restart or idle timeout should recover the same way §5.4 does; v1 only detects the tunnel case
recovering the Data Catalog's lazy loader InteractiveCatalogItem.fetch_children() runs on the connection the adapter captured when the tree was built, which §5.4's recovery cannot reach. Expanding an unloaded node after a drop fails with a Catalog Error and stays broken until the next full refresh replaces the tree. Bring it back when catalog items can be handed a new connection, rather than by reaching into each adapter's items
--ssh-batch-mode on by default in hsql interactive hsql users turn out to be rarer than scripted ones. Default-off is the same behavior in both commands today, which is easier to explain

Each is additive: the flags are namespaced, one class has no ABC to satisfy, and no adapter is
involved in any of it.


--- METRICS ---