# Technical Documentation: tconbeer/harlequin

> ℹ️ **Provenance:** Multi-Tier Ingestion · [CodeWiki Reference](https://codewiki.google/github.com/tconbeer/harlequin)

### Overview (`README.md`)

# Harlequin

[](https://pypi.org/project/harlequin/)




The SQL IDE for Your Terminal.



> [!TIP]
> This README contains a small subset of the docs available at
> [harlequin.sh](https://harlequin.sh/docs/getting-started/index).

## Installing Harlequin

Harlequin is a Python program, and there are many ways to install and run it. We strongly recommend using [uv](https://docs.astral.sh/uv):

1. [Install uv](https://docs.astral.sh/uv/getting-started/installation/#standalone-installer). 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](https://formulae.brew.sh/formula/harlequin) 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](https://harlequin.sh/docs/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](<https://en.wikipedia.org/wiki/PATH_(variable)>) (`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](https://harlequin.sh/docs/getting-started/index#installing-database-adapters), and the guides for individual [adapters](https://harlequin.sh/docs/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](https://harlequin.sh/docs/themes), [customize key bindings](https://harlequin.sh/docs/keymaps/index), [show remote and local files](https://harlequin.sh/docs/files/index), 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](https://harlequin.sh/docs/config-file/index).

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](https://harlequin.sh/docs/getting-started/hsql).

## Using Harlequin with Django

[django-harlequin](https://pypi.org/project/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](https://harlequin.sh/docs/getting-started/usage) 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](https://harlequin.sh/docs/troubleshooting/index) guide for help with key bindings, appearance issues, copy-paste, etc.

[GitHub Discussions](https://github.com/tconbeer/harlequin/discussions) are a good place to ask questions, request features, and say hello.

[GitHub Issues](https://github.com/tconbeer/harlequin/issues) are the best place to report bugs.

## Sponsoring Harlequin

Please consider [sponsoring Harlequin's author](https://github.com/sponsors/tconbeer), so he can continue to dedicate time to Harlequin.

## Contributing

Thanks for your interest in Harlequin! Harlequin is primarily maintained by [Ted Conbeer](https://github.com/tconbeer), but he welcomes all contributions!

Please see [`CONTRIBUTING.md`](./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](https://harlequin.sh), 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](https://harlequin.sh/docs/getting-started/hsql).

If you already use Harlequin and know about adapters and config files, jump ahead to [Running hsql](#running-hsql).

## Installing hsql

hsql is packaged with [Harlequin](https://pypi.org/project/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](https://docs.astral.sh/uv):

1. [Install uv](https://docs.astral.sh/uv/getting-started/installation/#standalone-installer). 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](https://harlequin.sh/docs/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](https://harlequin.sh/docs/config-file/index) 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](https://harlequin.sh/docs/config-file/index). 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](https://harlequin.sh/docs/hsql/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](https://agentskills.io) 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:

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

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

Visit [harlequin.sh](https://harlequin.sh/docs/getting-started/hsql) 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](https://github.com/tconbeer/harlequin/discussions) are a good place to ask questions, request features, and say hello.

[GitHub Issues](https://github.com/tconbeer/harlequin/issues) are the best place to report bugs.

## Sponsoring Harlequin and hsql

Please consider [sponsoring Harlequin's author](https://github.com/sponsors/tconbeer), 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](https://github.com/tconbeer), but he welcomes all contributions!

Please see [`CONTRIBUTING.md`](./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

```
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](https://github.com/tconbeer/harlequin/issues/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](https://github.com/tconbeer/harlequin/issues/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-<pid>.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.

<details>
<summary>Crash report</summary>

```
PASTE YOUR CRASH REPORT HERE
```

</details>

**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](https://github.com/tconbeer/harlequin/issues/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](https://github.com/tconbeer/harlequin/issues/524). This is the
foundation; everything else in this plan assumes it.

### Proposed surface

```
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](https://github.com/tconbeer/harlequin/issues/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](https://github.com/tconbeer/harlequin/issues/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.

```
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](https://github.com/tconbeer/harlequin/issues/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](https://github.com/tconbeer/harlequin/issues/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](https://github.com/tconbeer/harlequin/issues/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](https://github.com/tconbeer/harlequin/issues/767),
[#769](https://github.com/tconbeer/harlequin/issues/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](./m1-hsql-technical-plan.md): 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](./m2-hsql-technical-plan.md), 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](./m1-hsql-technical-plan.md),
[M2](./m2-hsql-technical-plan.md) and [M3](./m3-hsql-technical-plan.md) 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:

```
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

```
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-<uid>/`
(or `/tmp/hsql-<uid>/`) 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** —

```
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-<uid>/` 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-<uid>` 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 `PRAGMA`s,
  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](./harlequin-for-agents.md).
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](https://github.com/tconbeer/harlequin/issues/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

```
$ 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:

```
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.

```
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](https://github.com/tconbeer/harlequin/issues/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:

```
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

```
/* 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](./harlequin-for-agents.md),
following [the M1 technical plan](./m1-hsql-technical-plan.md). 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](https://github.com/tconbeer/harlequin/issues/667)), env interpolation
([#898](https://github.com/tconbeer/harlequin/issues/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.

```
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]
```

```
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**:

```
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:

```
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

```
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

```
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](https://github.com/tconbeer/harlequin/issues/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](https://github.com/tconbeer/harlequin/issues/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](./harlequin-for-agents.md),
following [M1](./m1-hsql-technical-plan.md) and [M2](./m2-hsql-technical-plan.md). 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.

```
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:

```
/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:

```
$ 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:

```
/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. */
```
                        ┌─ /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:

   ```
   /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 <profile>` — 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](./harlequin-for-agents.md),
following [M1](./m1-hsql-technical-plan.md), [M2](./m2-hsql-technical-plan.md) and
[M3](./m3-hsql-technical-plan.md). 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](https://github.com/tconbeer/harlequin/issues/1102), external
editor support, was opened while this plan was being written and carries a reference
implementation from Posting. [#952](https://github.com/tconbeer/harlequin/issues/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](https://github.com/tconbeer/harlequin/issues/850), the History screen's UI, and
[#429](https://github.com/tconbeer/harlequin/issues/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](https://github.com/tconbeer/harlequin/issues/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:

```
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](https://github.com/tconbeer/harlequin/issues/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](https://github.com/tconbeer/harlequin/issues/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:

```
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`:

```
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()`:

```
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 <profile>`. 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:

```
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:

```
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:
`<user_state_dir>/harlequin/open/YYYYmmdd-HHMMSS-<pid>.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 `<user_state_dir>/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](https://github.com/tconbeer/harlequin/issues/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](https://github.com/tconbeer/harlequin/issues/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:

```
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.

```
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
`<user_state_dir>/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](https://github.com/tconbeer/harlequin/issues/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](https://github.com/tconbeer/harlequin/issues/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](https://github.com/tconbeer/harlequin/issues/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](https://github.com/tconbeer/harlequin/issues/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](https://github.com/tconbeer/harlequin/issues/429) and
[#850](https://github.com/tconbeer/harlequin/issues/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.

```
   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

```
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:

```
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()
```

```
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

```
$ 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 |

```
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](https://github.com/tconbeer/harlequin/issues/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 ---
- Files Extracted: 14
- Estimated Token Budget: ~107438 tokens
- Canonical Reference: https://codewiki.google/github.com/tconbeer/harlequin
