Technical Documentation: tconbeer/harlequin
ℹ️ Provenance: Multi-Tier Ingestion · CodeWiki Reference
Overview (`README.md`)
Harlequin
The SQL IDE for Your Terminal.
[!TIP]
This README contains a small subset of the docs available at
harlequin.sh.
Installing Harlequin
Harlequin is a Python program, and there are many ways to install and run it. We strongly recommend using uv:
Install uv. From a POSIX shell, run:
bashcurl -LsSf https://astral.sh/uv/install.sh | shOr using Windows Powershell:
powershellpowershell -ExecutionPolicy ByPass -c "irm https://astral.sh/uv/install.ps1 | iex"Install Harlequin as a tool using
uv:bashuv tool install harlequinThis 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:
pip install harlequinThere is also a Homebrew formula for Harlequin, although this is maintained by the community and is not as rigorously tested as the Python installations. Note that the formula includes several Harlequin adapter packages (Postgres, MySQL/MariaDB, and ODBC) and their dependencies, which is convenient but increases the application size.
brew install harlequinInstalling Database Adapters
Harlequin can connect to dozens of databases using adapter plug-ins. Adapters are distributed as their own Python packages that need to be installed into the same environment as Harlequin.
For a list of known adapters provided either by the Harlequin maintainers or the broader community, see the adapters page.
The adapter docs also include installation instructions. Some adapters can be installed as Harlequin extras, like postgres. If you used uv to install Harlequin:
uv tool install 'harlequin[postgres]'You can install multiple extras:
uv tool install 'harlequin[postgres,mysql,s3]'Running Harlequin
Once Harlequin is installed, you run it from the command line. The arguments and options you pass in at the command line affect Harlequin's behavior, like what database adapter it uses, which database it connects to, whether or not the file picker is visible, and more. Assuming you have installed Harlequin so that it is on your PATH (uv tool install harlequin does this automatically), you run Harlequin by typing a command of this form into your shell:
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:
harlequin --helpUsing 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:
harlequinTo 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):
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:
harlequin --adapter sqliteYou 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:
harlequin -a sqlite "path/to/sqlite.db" "another_sqlite.db"Other adapters can be installed as plug-ins; for more information, see the installation guide, and the guides for individual adapters. Each adapter can define its own options, which you can view using harlequin --help.
Configuring Harlequin
Harlequin contains a large number of options that allow you to set the theme, customize key bindings, show remote and local files, set the locale for number formatting, and much more. These can always be entered at the command line, but it can be convenient to define a configuration as a profile instead. For more information on configuring Harlequin, see Using Config Files.
A profile's string values can name environment variables — password = "${MYPASSWORD}", or ${MYHOST:-localhost} to supply a default — so a config file your team shares holds no credentials, and values an adapter declares as secrets are masked wherever Harlequin prints them. hsql, Harlequin's companion CLI (below), reads the same files and can report on them, with hsql --config show, --config validate and --config schema.
Using Harlequin with Agents or in Scripts
Harlequin has a companion CLI, hsql, that uses the same adapters and config files, but is optimized for headless use by agents and in automations. hsql is packaged with Harlequin (no additional installation required):
$ hsql -P dev -c "select * from users"
id | name
----+---------
1 | Ted
2 | Patrick
(2 rows)hsql can also explore the catalog without writing SQL: hsql --catalog --path mydb.analytics lists a schema's relations (and --path mydb.analytics.orders lists that table's columns), while hsql --catalog-search customer_id searches every level at once.
To bound what an agent can do, --read-only connects in a mode the database refuses writes in, and hsql --timeout 30 cancels a run that takes longer than that. Both are also profile keys, and both refuse to start if the adapter cannot enforce them. Harlequin takes --read-only too.
For more information on hsql, see the getting started docs.
Using Harlequin with Django
django-harlequin provides a command to launch Harlequin using Django’s database configuration, like:
./manage.py harlequinKeep Reading at [harlequin.sh](https://harlequin.sh/docs/getting-started/usage)
Visit harlequin.sh for an overview of features and full documentation, starting with a guided walkthrough of how to edit and execute queries, use the data catalog, export data, and more.
Getting Help
To view all command-line options for Harlequin and all installed adapters, after installation, simply type:
harlequin --helpTo view a subset of these docs (and a link back here) from within the app, press F1.
See the Troubleshooting guide for help with key bindings, appearance issues, copy-paste, etc.
GitHub Discussions are a good place to ask questions, request features, and say hello.
GitHub Issues are the best place to report bugs.
Sponsoring Harlequin
Please consider sponsoring Harlequin's author, so he can continue to dedicate time to Harlequin.
Contributing
Thanks for your interest in Harlequin! Harlequin is primarily maintained by Ted Conbeer, but he welcomes all contributions!
Please see CONTRIBUTING.md for more information.
Overview (`packaging/hsql/README.md`)
hsql
hsql is your agent's favorite SQL client. It's the headless CLI for
Harlequin, and shares the same config and query engine,
with an interface optimized for agents, scripts, and automations.
[!TIP]
This README contains a small subset of the docs available at
harlequin.sh.
If you already use Harlequin and know about adapters and config files, jump ahead to Running hsql.
Installing hsql
hsql is packaged with Harlequin, so if you already use Harlequin,
hsql is already installed.
Otherwise, you can install hsql directly. hsql is a Python program, and there are many ways to install and run it. We strongly recommend using uv:
Install uv. From a POSIX shell, run:
bashcurl -LsSf https://astral.sh/uv/install.sh | shOr using Windows Powershell:
powershellpowershell -ExecutionPolicy ByPass -c "irm https://astral.sh/uv/install.ps1 | iex"Install hsql as a tool using
uv:bashuv tool install hsqlThis command will install hsql into an isolated environment and add it to your PATH so you can easily run the executable.
Installing Database Adapters
hsql can connect to dozens of databases using adapter plug-ins. Adapters are distributed as their own Python packages that need to be installed into the same environment as hsql and harlequin.
For a list of known adapters provided either by the Harlequin maintainers or the broader community, see the adapters page.
The adapter docs also include installation instructions (installing an adapter with Harlequin also installs it for hsql). Some adapters can be installed as Harlequin extras, like postgres. If you used uv to install hsql directly, you can add adapter packages using --with:
uv tool install hsql --with harlequin-postgresYou can install multiple extras:
uv tool install hsql --with harlequin-postgres --with harlequin-mysqlRunning 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:
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:
hsql --helpUsing 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:
$ 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):
$ hsql "path/to/duck.db" -tAc "select count(*) from orders"
42Using 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:
$ 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:
$ 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:
$ hsql -a postgres "postgresql://example.com/postgres:5432" -c "select * from invoices"[!TIP]
You should use Profiles to keep credentials out of your shell
history. For more information, keep reading or see
the docs on
config files.
Configuring hsql and Using Profiles
hsql supports a number of options for setting the query limit, configuring output formats, and defining connection parameters. Options can be passed as command-line flags, or read from config files. Config files store configurations under separate profiles, so you can easily switch between databases by reading from different profiles with the -P option:
$ 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.pqhsql 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:
[profiles.prod]
adapter = "postgres"
host = "${MYHOST:-localhost}" # ${VAR:-default} supplies a default
password = "${MYPASSWORD}" # ${VAR} is required; hsql exits 2 if it is unsetWrite $${ 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;--jsonfor JSON.validate— every problem in every discovered file, exiting2if 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 -1writes[profiles.prod]into the nearest config file.
$ 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 = 100list-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:
$ 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:
$ 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:
$ 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:
$ 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,tSearching 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:
$ 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:
$ 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|tNote: 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 -1or setlimit = -1in 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 with2>/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):
$ 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.csvhsql can execute multiple statements in one invocation, and supports several methods for doing so:
- Pass
-cmultiple times - Include multiple queries, separated by
;, in one-coption - Pass one or more .sql files with
-f, with multiple statements in each - Use
--resultto define which queries output data to stdout - Use
--on-errorto eitherstoporcontinueif one or more queries produces an error.
In other words, this works:
$ 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:
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 devruns a session nameddev, andhsql --session dev -c "..."(orHSQL_SESSION=dev) sends invocations to
it, which answer in milliseconds. A session is state rather than a cache:
temp tables, settings and open transactions survive from one invocation to
the next. POSIX only; see
warm sessions.
Running Safely
Two options bound what an invocation can do, and hsql refuses to run at all if the adapter cannot enforce them.
--read-only (or -r) connects in a mode the database itself refuses writes in:
$ 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:
$ hsql --timeout 0.5 -c "select count(*) from range(100000000000) t(i)"
hsql: error: timed out after 0.5sBoth are also profile keys:
[profiles.agent]
adapter = "postgres"
read_only = true
timeout = 30hsql 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.
$ hsql --info -a sqlite | jq '.adapters.sqlite'
{
"distribution": "harlequin",
"version": "2.10.0",
"capabilities": {
"implements_cancel": true,
"implements_catalog_search": true,
"implements_read_only": true,
"implements_validate_sql": false
},
"error": null
}Neither connects to a database, both write JSON to stdout, and -a NAME narrows either one to a single adapter, which is much faster than importing them all.
Installing the Agent Skill
hsql ships an Agent Skill that teaches an agent how to drive it: ask before you assume, keep credentials off the command line, read the catalog before writing SQL, consider a warm session, watch the 500-row limit, branch on the exit code, and hand off to harlequin when a human should drive. hsql --skill installs it, with no network and matched to the version you have:
$ 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.mdWith 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@harlequinKeep Reading at [harlequin.sh](https://harlequin.sh/docs/getting-started/hsql)
Visit harlequin.sh for an overview of features and full documentation.
Getting Help
To view all command-line options for Harlequin and all installed adapters, after installation, simply type:
hsql --helpGitHub Discussions are a good place to ask questions, request features, and say hello.
GitHub Issues are the best place to report bugs.
Sponsoring Harlequin and hsql
Please consider sponsoring Harlequin's author, so he can continue to dedicate time to hsql.
Contributing
Thanks for your interest in Harlequin! Harlequin and hsql are primarily maintained by Ted Conbeer, but he welcomes all contributions!
Please see CONTRIBUTING.md for more information.
Differences from psql
-c, -f, -t, -A and -x mean what they mean in psql, so hsql -tAc "select count(*) from orders" prints a bare number, exactly as it would there. These do not:
| psql | hsql | |
|---|---|---|
-P |
--pset, an output setting |
--profile, a config-file profile |
| Field separator | -F |
--csv, --format tsv, or any other --format |
| Listing databases | -l |
--catalog |
| Describing an object | \d, \dt |
--catalog --path, --catalog-search |
| Stopping on the first error | -v ON_ERROR_STOP=1 |
--on-error stop, which is the default |
| One transaction | -1 |
write begin and commit in your script |
-o |
a file for query output | a file, or a directory that gets one file per result set |
| Connection flags | -h, -p, -U, built in |
the adapter's, so hsql --help -a postgres lists them |
| Row limits | none | 500 rows by default; --limit -1 removes it |
| Suppressing chatter | -q |
nothing to suppress: stdout is only ever results |
| Exit codes | 1 its own error, 2 connection, 3 script error |
1 query error, 2 usage/config, 3 connection, 4 timeout |
Overview (`tests/skill_evals/README.md`)
Skill trigger evals
trigger_queries.json is the eval set for the hsql skill's description, which is the
only part of a skill always in context and the whole of what decides whether it loads.
Run it with the description-optimization loop in the skill-creator skill, which needs
the claude CLI. It is a pre-merge exercise on a change to the description, not a CI
check: scoring it costs model calls, and the answer is a rate rather than a pass or fail.
Ten positives and ten negatives, split train/test so a description tuned on one half is
scored on turns it was never tuned against. The positives include turns that never say
"hsql" or "harlequin", because under-triggering is the failure mode; the negatives are
near misses — SQL-shaped work that is not a query, and database-shaped work that belongs
to another tool.
It lives here rather than beside the skill becausesrc/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:
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 ownADAPTER_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 inharlequin/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
Decide whose it is. If it changes how a database is reached, it is an
adapter option — declare it in that adapter'sADAPTER_OPTIONSand stop.
Everything below is for a core option onharlequinorhsql.Write the
CoreOptioninharlequin/core_options.py, among the ones it
belongs with — where it sits in that list is wherehsql --helplists it.
Give itharlequin=On(),hsql=On(), or both, and put inkwargswhat
both commands share; anOn's ownkwargsmerge over those, and itsdeclsreplace them, which is for a difference the two commands really
have. Forhsql, keep the help one or two sentences and name the default.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
hsqltakes 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.- once, at connect time →
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 declaresfirst_pass=True
and goes in theneeds_profile/needs_adapterpredicates inbuild_cli()too, so the first pass reads no profile for it; a mode that is
configured like a run does not (--catalog,--history).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_KEYSandCLI_ONLY_SSH_KEYS, which are what
refuse it, are derived from the flag.An option the IDE reads and
hsqlmust not is one with nohsql=:TUI_ONLY_KEYSis every declarationharlequintakes andhsqldoes not.Regenerate the committed artifacts, which are pinned by tests:
bashuv run python scripts/write_config_schema.py uv run python scripts/write_cli_reference.pyAdd a
CHANGELOG.mdentry 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: 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_cacheis called from exactly one place,Harlequin.action_quit()(app.py:970). A crash, akill -9, a closed terminal or a dropped ssh
session loses every open buffer — as does theself.exit(return_code=2)path. - The reports we get are thin.
bug_report.mdasks for version, adapter, shell, terminal, OS,
locale and install method as free text, so most reports arrive missing half of it. - A crash at startup could repeat forever. #745
crashed insideEditorCollection.on_mountwhile 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 torecovery-.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`
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(), thenos.replace(). Same-directory means a same-volume rename, atomic on
POSIX and on Windows. Thefsyncis what stops a power loss leaving a zero-length file. - Never raises. Returns
FalseonOSError/pickle.PicklingError. Both callers run where an
exception would itself take the app down. On Windowsos.replaceraisesPermissionErrorwhen
another process holds the destination open, so a checkpoint has to be allowed to skip silently. adopt_recovery()globsrecovered-*.pickle(written by the crash handler) newest first, thenrecovery-*.picklewhose mtime is older thanRECOVERY_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.replayedbefore 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(), sotests/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 incompose(), not__init__; - build
Cache(focus_index=..., buffers=...)on the message pump (thebuffersproperty queries
widgets —code_editor.py:294calls_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/BufferStateare dataclasses andSelectionis 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=Truecancelling one
mid-flight while its thread still completes the write) for no measured win.
- bail when
action_quit()keeps itswrite_editor_cache(...)and then callsclear_recovery().
Restoring (`components/code_editor.py`)
EditorCollection.__init__ keeps self.startup_cache = load_cache() and addsself.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 asinfo._adapters already does.
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()reportsssh: True/Falseand never the value ofSSH_CONNECTION, which
holds IP addresses.sizefromshutil.get_terminal_size().install_facts()readsdistribution("harlequin").read_text("INSTALLER")(pip/uv/pipx write it)
andsys.prefix != sys.base_prefix— that answers the bug template's install-method checkbox
automatically.adapter_factsisinfo._adapters+info._capabilitiesmoved verbatim, minus thediagnostics.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 bytest_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 intests/unit_tests/test_import_hygiene.py. Both have to stay headless anyway, because hsql imports
them.
`crash.py` (new, headless)
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/harlequinon macOS,~/.local/state/harlequin/logon Linux,%LOCALAPPDATA%\harlequin\Logson 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. Matchconfig.py'sappauthor=False—editor_cache/catalog_cacheomit it and so nest twice on Windows; don't copy
that. root_causeunwraps a TextualWorkerFailedviagetattr(error, "error", None)rather than
importing it, so this module stays headless. It matters:database_tree.py:498(_loader) andcode_editor.py:88(read_symbols) are the two@workdecorators insrc/that omitexit_on_error=False, so they do reach_handle_exception, wrapped. A report namingWorkerFailedis a useless report.- Content, in order: a header telling the user to review the file before sharing and linking
ISSUE_URL;runtime_report(); thecontextthe caller supplied;traceback.format_exception
for the root cause and, when different, the wrapper — noshow_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, matchinghsql/diagnostics.py::_write's discipline. - Pruning. Newest
CRASH_REPORT_KEEPkept. Distinct filenames per crash, so a loop cannot
destroy the evidence.write_crash_reportreturnsNoneand raises nothing when the directory is
unwritable.
exception.py gains class HarlequinCrashError(HarlequinError): pass, so the panel ispretty_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:
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. */
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 andAbort/KeyboardInterrupt clauses:
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. */
---
name: Crash Report
about: Harlequin or hsql exited unexpectedly and wrote a crash report.
title: 'Crash: '
labels: 'bug'
assignees: ''
---
**Before proceeding, please acknowledge**:
- [ ] I have searched Issues and Discussions in this repo for this error.
**Paste your crash report below.**
Harlequin and `hsql` write a crash report and print its path when they exit
unexpectedly. If you still have that message on screen, the path is in it.
Otherwise, the reports are here:
| OS | Location |
|---|---|
| macOS | `~/Library/Logs/harlequin/` |
| Linux | `~/.local/state/harlequin/log/` |
| Windows | `%LOCALAPPDATA%\harlequin\Logs\` |
The newest `crash-*.log` is the one you want.
> [!IMPORTANT]
> Please read the report before pasting it. It contains your configuration
> (with passwords masked) and the SQL that was in your active buffer.
Crash reportPASTE YOUR CRASH REPORT HERE
**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
- [ ] Nobug_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.
write_cacheatomic and non-raising.editor_cache.pyonly; no behavior change visible to the app.- Recovery file + rename-before-replay.
editor_cache.py,code_editor.py,app.py'sclear_recovery, conftest patches. Nothing writes a recovery file yet, so this is provably inert
on the happy path. - The checkpoint timer — commit 2's machinery gets a producer.
environment.py+ theinfo.pyrefactor. Acceptance:hsql --infobyte-identical. Import-linter
contract andHEADLESS_IMPORTSin the same commit.crash.py,HarlequinCrashError,AppBase._handle_exception,Harlequin's two hooks,hide_secrets_in,ctx.exit(return_code), and thetest_cli.pyfixture fix.ExitCode.CRASH,diagnostics.report_crash, themain()handler, hsql's_crash_context..github/ISSUE_TEMPLATE/crash_report.mdand thebug_report.mdpointer.
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 totests/conftest.py, autouse and session-wide with no marker opt-out, in the spirit of the existingno_discovered_config:
@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_dirExtend 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 existingharlequin.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 returnsFalse; 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, mirroringtest_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 returnsNone on PermissionError.
tests/functional_tests/test_crash.py (new), injecting through _handle_exception directly:
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 notQUERY; 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
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 -n0By hand:
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.- Temporarily raise from a key handler and run without dev mode (
uv run harlequin f1.db, notmake 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. - Temporarily raise from
EditorCollection.on_mount, crash, remove the raise, restart twice: the
second start is clean and the poisoned file is.replayed. make serveand crash: the full traceback still prints for developers.- 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. - 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_crashmay write a newrecovered-*.pickleholding 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.
harlequinreturns 1 on a
crash and 2 on a config or connection error;hsqlreturns 70 where it previously let Python exit- Correct, but user-visible, so both get a changelog line.
- The report names
sys.executable, the config path andSHELL, all of which reveal a username —
and all of whichhsql --infoalready prints, so this is consistent rather than new.
Deliberately out of scope
_handle_worker_errorswallows unknown worker errors
(#1117).app.py:576branches on two worker
names with noelse, 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.pyexists, but it is the only piece with snapshot churn: a visible version or Python
row bakes2.12.2and3.10.14intoDebug Info Screen.svg, which then fails on every CI Python
and after every release. If picked up, put the rows in a collapsedCollapsible(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, whichAdapterDebugInfo.parse_info()never produces — dead code with a latentNoMatches. - Narrowing hsql's broad
except Exceptionhandlers so an internal bug caught insidecli.py
reportsCRASHrather thanQUERY. - A prefilled GitHub issue URL (the ~8KB URL cap cannot carry a traceback) and converting the
templates into YAML issue forms. - A
harlequin --debugflag: the report already holds the full traceback, andTEXTUAL=debug/make servealready 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-specificinformation_schemaqueries. - 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.
One engine, two front doors.
harlequin(TUI) andhsql(headless) share
adapters, config discovery, profiles, limit semantics, and one execution core.
Neither reimplements the other. See §4.stdout is data; stderr is narration. Results go to stdout and nothing else does.
Truncation notices, warnings, errors, and--statsgo 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 inexception.py— make it a hard,
tested contract.Exit codes are an API. Documented, stable, and distinct enough that an agent can
branch on them without parsing text.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 followsNO_COLOR/--color.)Token efficiency is a feature. Results cost the agent money. Defaults should be
small, and truncation must always be announced — never silent.Be boring and psql-shaped. An agent's prior is
psql, secondarilyduckdb.
Every flag we can borrow, we borrow. Novel spellings cost tokens and cause retries.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.Safety is a product feature, not a warning in the docs.
--read-only,--timeout, and--dry-runare what let a human hand an agent a warehouse
credential without flinching.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 onharlequin.
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, soharlequin -c "select 1" fails with "-c isn't a harlequin option — did you meanhsql -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 matchesharlequin --config, so
one shape covers both commands — and it sidesteps the objection this section raises
against subcommands, which applies here too:hsqltakesCONN_STRpositionally, sohsql catalogand a database file namedcatalogwould have needed a disambiguation
rule, docs for the rule, and a test for the rule. No verb, no rule.
Consequences
- The
-fcollision disappears, and with it the plan's only breaking change.harlequin -fstays--show-filesforever;hsql -fis--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. hsqlgets clean access to-c,-f,-o,-A, and friends, so psql muscle memory
works without compromise.hsql --helpomits 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 athsql --help -a <adapter>for
one adapter's options. That keeps the first thing an agent reads small and stable, and
keeps--helptrue for every adapter rather than showing whichever one is the
default.- The handoff gets a natural verb:
hsql open query.sqlreads much better thanharlequin --new-buffer query.sql.
Alternatives rejected
- Flags on
harlequin. Requires the 3.0-fbreak, grows an already-large help
surface, and leaves cold start and the stability boundary as matters of discipline. - Subcommands on
harlequinwith a bare-invocation TUI fallback
(harlequin query -c ...). Needs a custom ClickGroupthat falls back to the TUI
when the first argument isn't a known subcommand, and it is genuinely ambiguous: a
DuckDB file namedcatalogorconfigin the cwd resolves as a subcommand. Low
probability, but exactly the confusing failure we don't want an agent hitting.
On the name
hsql is free on PyPI as of this writing — M0 in §11 secures it before anything else
starts. HSQLDB ships sqltool and java -jar, not a binary named hsql, so there's no
PATH collision — the cost is search-results mindshare with
HyperSQL, which is real but mild and fading. hq is taken. harlequin-cli is free but
too long for something an agent types a hundred times in a task; short names are cheap
in tokens and cheap in working memory.
I'd take hsql. It's obviously "harlequin sql," and it sharpens the positioning rather
than diluting it:
Harlequin — the SQL IDE for your terminal.
hsql — the same engine, headless, for scripts and agents.
Fifteen databases, one adapter ecosystem.
That's a better story than "one tool with a flag."
5. Workstream A — `hsql`, headless execution
Closes #524. This is the
foundation; everything else in this plan assumes it.
Proposed surface
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-loff--limit. The format is chosen by--formatspelled out, or by
the--csv/--json/--jsonl/--markdown/--verticalshorthands, 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 getsnote: 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 exactShowing 100,000 of 3,412,887. hsql deliberately doesn't buy that number, so it says>500; only --limit -1 yields an exact count. The TUI's default of 100,000 is
right for a TUI and a catastrophe for an agent; hsql defaults to 500.
Amended by #1026. One key
naming both of those limits was the bug, not the design: the TUI's display cap is nowviewer_max_rows,limitis the hard fetch limit in both commands, and unlimited is
spelled-1rather than0, so that--limit 0can 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:
{"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.
--timeouthas to attribute the cancellation itself. A cancelled DuckDB query comes
back as an empty result set with no error —fetchall()swallows the interrupt and
returnsNone, which is also what an empty result looks like — so a timeout that trusts
the adapter prints "no rows" and exits 0.hsqlknows 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-transactionis 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 frombeginandcommitin the caller's own script until then.
--dry-runis cut, from M2 and possibly for good. Only DuckDB implementsvalidate_sqlat all, and there it is a parse check rather than an existence check:select * from no_such_tablevalidates clean, because the adapter treats every
non-parser error as valid so that DDL passes. Making it honest would mean aPREPARE
pass in the DuckDB adapter plus a refusal path on every other adapter — a lot of
machinery for a flag that would be truthful on one backend.
Exit codes:
| Code | Meaning |
|---|---|
| 0 | Success |
| 1 | Query error (the database rejected the SQL) |
| 2 | Usage / config error (bad flag, bad profile, bad TOML) |
| 3 | Connection error (couldn't reach or authenticate to the database) |
| 4 | Timeout / cancelled |
| 130 | SIGINT |
Cold start is a feature. An agent may run twenty queries in a task. Target:hsql -c "select 1" against DuckDB in under 300ms, tracked as a benchmark in CI and
protected by the import-linter rule from §4.
Large results stream — in M5, not M1. Streaming has to start at the cursor:HarlequinCursor.fetchall() materializes the whole result before any writer exists, so
no amount of care in the format layer helps. Deferred wholesale to M5 along with
#875, rather than half-designed
now.
User stories
- A1. As an agent, I run
hsql -P prod -c "select count(*) from orders"and get a
small, aligned result on stdout and exit code 0, so I can report a number to the human
without parsing a TUI. - A2. As an agent, I run the same command against Postgres, BigQuery, and DuckDB and
the flags, output shape, and exit codes are identical, so I don't relearn a CLI per
database. - A3. As an agent, when my SQL is wrong I get a one-line error on stderr and exit
code 1, and nothing on stdout, so I can distinguish "the query failed" from "the query
returned zero rows." - A4. As an agent, when a result is larger than the limit I am told so explicitly, so
I never report a truncated aggregate as a complete one. - A5. As an agent, I pass
--jsonand pipe tojqwithout stripping banners,
timings, or a "(500 rows)" footer. - A6. As an agent, I run a multi-statement script with
-f setup.sql --single-transactionand either all of it applies or none of it does. - A7. As a human, I set
read_only = truein 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
hsqlin 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 onefetch_children()call.--depth Nopts 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, soanalytics.*is just
sugar foranalytics, andanalytics.ord*narrows it. An interior wildcard
(*.orders,mydb.*.orders) can't be evaluated without fetching every candidate
level, so it belongs tofindbelow — never something acatalogcall does quietly.Report child counts wherever the adapter can get them cheaply.
400 relationsnext
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.--statsreports 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 compactstays 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.describereturns 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 Nis 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
withfetch_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.
describeiscatalogon 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 compactis 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 optionaltype_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 csvover 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): oneinformation_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 inhsql 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 singleinformation_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 catalogand thenhsql -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 relationsand 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 ordersand 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 fromAbstractOption, 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 --jsononce 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:HarlequinSqliteConnectiondefinesvalidate_sqland raisesNotImplementedErrorfrom
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 onehsql --infocan read while the
database is unreachable, which is when a diagnostic is worth most. So--infoimports
adapters and opens no connection.
- B5. As an agent debugging a human's setup, I run
hsql info --jsonand 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 betweenharlequin and hsql, so this serves both.
Deliverables
- A published JSON Schema for
.harlequin.toml, athttps://harlequin.sh/schemas/config/v1.jsonand shipped in the package. Enables the#:schemacomment 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 havehsql config schema --jsongenerate a locally-accurate one that
includes the installed adapters' options.
- Wrinkle: adapter options are dynamic. Solution: publish a base schema for the
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_schemamessages are already good; they just need a machine channel.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.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.- Environment variable interpolation (
${DB_PASSWORD}) in config values — closes
#898, and it's a prerequisite for
agents and CI ever touching a config that involves secrets. - 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 adict.update()per file, so a cwd config file
that defines any profile replaces the home file's wholeprofilestable — while leaving
itsdefault_profilein 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-refactorconfig.pyas well, so it is long-standing rather than something thetomllibchange 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 addshsql --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 --configreads
the config and writes it back, so resolving values too early bakes a plaintext password
into the user's file on their next wizard run. The syntax is${VAR}and${VAR:-default}— a bare{never triggers it, so nothing anyone has in a password
today needs escaping — and an unset variable with no default is a config error naming
the variable and its file, never an empty string.
Secrets: #667, #898, and redaction are one feature
#667 asks for a password prompt, and
the maintainer's read at the time was that it wants a new type of adapter option.
That's the right shape, and it does far more than serve the prompt: it turns redaction
from a discipline into a property of the declaration.
The decisive argument is the plugin ecosystem. Core cannot enumerate every adapter's
secret — --service-account-key, --token, --tls-key, whatever the next adapter
invents. But each adapter can declare its own, once, and then every consumer gets it
free: hsql info --json, config show, spec --json, error output, the debug-info
screen, and the config wizard's masked input.
Combine that with ${VAR} interpolation (#898) and the secrets story closes:
PasswordOption(orsecret=TrueonAbstractOption) — declares an option as
sensitive. Driveshide_inputat 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.hsqlmust fail fast with a message pointing at the profile or the
env var.Amended in M2's plan: no
--password-stdineither. 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 --jsonmarks secret options. This is what teaches an agent not to
constructhsql --password hunter2, which would leak throughpsand 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_stris a positional argument, so
no option type coverspostgres://user:pw@host/db. It needs DSN-aware redaction of its
own — and #354 is evidence that
people really do put passwords there.History is the one surface this can't fix.
create user x password 'y'is a secret
inside a query. Worth saying plainly in the docs that history is local-only and may
contain secrets by construction.
Stories
- C0. As a human, I hand my agent a profile name, and no secret ever appears in an
argument list, a transcript, orpsoutput. - C1. As an agent, I add a
prodprofile 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
hsqlCLI — 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.txtand 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:
- Harnesses without shell access — Claude Desktop, chat clients, some IDE
integrations. The CLI is invisible to them. - 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-onlyunless 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.sqllaunches the TUI with the query loaded in a new buffer,
not executed. The agent drafts; the human reviews, edits, and runs. Buffers already
persist througheditor_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>' --csvto the clipboard, so a human can hand their agent something exactly
reproducible. - Readable history (Workstream B) closes the loop the other way.
On issue #952 — natural language to SQL
I don't think Harlequin should embed an LLM. No API keys, no model selection, no
provider coupling, no support burden for someone else's rate limits.
But there's a middle path that fits Harlequin's plugin ethos and answers the actual
request: a configurable external-command hook. Let the user define, in their config,
a command that receives the current buffer (and optionally the catalog) on stdin and
whose stdout is inserted into the editor. Then Ctrl+G runs whatever the user
already trusts — claude -p, llm, a local model, a company-internal endpoint. Same
shape as the existing external-editor request (#767,
#769), and it inherits their
credentials rather than asking for new ones.
Stories
- F1. As an agent, I draft a migration and
hsql openit in the human's TUI for
review rather than executing it myself. - F2. As a human, I explore interactively, then copy a reproducible
hsqlcommand 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 toharlequin and can ship in 2.x minors. Nothing waits for a major version.
| Milestone | Theme | Contents |
|---|---|---|
| M0 | Secure the name | Publish hsql to PyPI as a metapackage depending on harlequin, so pip install hsql works today and the name can't be taken while the rest of this ships. |
| M1 | hsql |
Second console script; extract the shared execution core; import-linter rule and cold-start benchmark in CI. -c, -f, stdin, -o, --format + shorthands, default --limit 500, truncation notices, --stats, exit codes, --on-error, --color/NO_COLOR, plain errors. Unknown-option hint on harlequin. Docs: the "Headless & Agents" topic, seeded. Closes #524. |
| M2 | Self-description & safety | catalog (one level below the match, child counts, node budget), describe, info --json, spec --json, fmt, config validate/show/schema/init, capability flags, secret option type + declarative redaction (#667), env interpolation (#898), --read-only, --timeout, --dry-run. Published JSON Schema. Stretch: find + implements_catalog_search, optional fetch_descendants. |
| M3 | Docs for machines | llms.txt, llms-full.txt, raw .md routes, Docs API v1, copy-as-markdown, homepage positioning, AGENTS.md. |
| M4 | Skill & handoff | Skill, hsql open, JSONL history, "Copy CLI command", external-command hook. |
| M5 | Scale | Streaming output, --offset/pagination, memory work for large results (#875). |
| M6 | MCP | hsql mcp over the M1 execution core: run_query, list_catalog, describe_object, explain_query, format_sql, plus catalog and docs resources. Read-only by default. |
On M1. Implementation is planned in detail in
the M1 technical plan: the module architecture, the
refactors that have to land first, and an eight-PR sequence across two releases. A few
figures and defaults in this document were corrected against measurements taken while
writing it; §8 there lists them. PRs 1–6 shipped and PR 7, the docs topic, is in flight;
PR 8, the agent eval suite, was not built.
On M2. Likewise in the M2 technical plan, across three
releases rather than one: config and self-description first, since they fix bugs that
exist today and need no adapter change at all; then the catalog, with search implemented for
both in-tree adapters rather than left as a stretch; then the safety flags, which need
additive members on the adapter contract and therefore an ecosystem rollout. The amendments
marked above are the corrections that came out of measuring it. This table's M2 row is
otherwise all subtraction: the introspection surface is mode options rather than
subcommands, and describe, fmt, --dry-run, --single-transaction, --password-stdin,
recursive --depth, the node budget, child counts and the compact format are all cut —
each for a reason given in §7 of that plan.
On M0. Name availability is the only thing in this plan that someone else can take
while we deliberate, which is why it's a milestone rather than a task. It's also cheap
enough to do this week.
A few things worth getting right the first time:
- Metapackage, not a second copy of the artifact.
hsqlshould be a small
distribution whose only dependency isharlequin. 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.0requiringharlequin>=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
hsqlcommand that prints
"coming soon," but from M1 theharlequindistribution itself provides that entry
point, and two installed distributions claiming the same script name is a mess to
unwind.pip install hsqlgiving 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 anduv 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
hsqlwith-c/-f/-o/--format, a strict stdout/stderr split,
documented exit codes, and honest truncation.hsql catalog --format compact.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
hsqlgrows 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 -cis 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.
hsqlcompetes 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 bulkfetch_descendantsare optional adapter methods.hsqlmust 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 2on 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--statsare 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
bareharlequin --versiontakes 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.adapterpays 265ms fortextual-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 --helpandhsql --helpshare the
adapter option matrix and must not disagree. Generate both from the same option
definitions. - Docs drift.
spec --jsonoutput 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
hsqlmodule graph contains notextualimport, 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 --helpcompletes a query task with zero wrong-flag
retries. llms.txtis served, and every docs page is fetchable as clean markdown.- #524, #667, and #898 closed; #952 answered without embedding an LLM.
- Qualitatively: someone writes "just use hsql, it works the same everywhere" in a thread
about getting an agent to talk to a database.
Plans Hsql Warm Sessions Design (`docs/plans/hsql-warm-sessions-design.md`)
Warm sessions for `hsql` — a design for `hsql --serve`
A design for a long-lived hsql process that holds an open connection, so that repeated
invocations pay neither the import cost nor the connection cost again.
Revised against 2.13.0, with M1,
M2 and M3 shipped and SSH
tunnels landed; the first draft was written against 2.9.0, when M2 was still a plan. Every
number below is re-measured on this checkout — Python 3.10.15, Linux, four adapters
installed — on a machine about a third slower than the one the first draft used, so read
the ratios rather than the absolutes. §10 lists what the revision changed, for a reader
who has the first draft in their head. PR 1 has shipped; §9 says what is in it.
Bottom line up front. The idea works, and the win is bigger than the quarter-second it
was proposed to recover — but only if one constraint is treated as the load-bearing one:
the client must not import anything Harlequin owns. The client built to that rule and
shipped in PR 1 answers select 1 in 27ms against 348ms cold, and 20 sequential
invocations in 0.55s against 7.5s. A client that imported click and nothing else
would already be near 65ms, and one that imported harlequin.config near 90ms (§2) — so
the difference between a good version of this feature and a mediocre one is decided by the
first few lines of the console script, not by the protocol.
The other finding is that speed is the smaller half. A warm process is a session:
temp tables, SET, search_path, and open transactions survive between invocations. That
is a genuine feature for an agent — and it is also the reason this cannot be turned on by
default, cannot be auto-started in v1, and cannot be a silent fallback. §5 is the part of
this document I would argue about; §1–§2 are mostly arithmetic.
Recommended shape, in one line:
hsql --serve prod ./warehouse.db # foreground, holds the connection
HSQL_SESSION=prod hsql -c "select 1" # 27ms, same bytes, same exit code1. 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 lazytextual-fastdatatable __init__, deferred harlequin.query — and what is left is
genuinely irreducible on the cold path:
- pyarrow, 103ms with the backend.
harlequin.querynormalizes every result set
throughcreate_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 eachconnect(), 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 withimport click. It now looks atHSQL_SESSIONand--sessionfirst — asys.argvscan and anos.environlookup, both free — and only
falls through tobuild_cli()when there is no session. harlequin.hsql.diagnostics, 68ms. The bigger one, and invisible until measured:harlequin/hsql/__init__.pyimportedExitCodefrom it at module scope, and that
module reachesharlequin.redact→harlequin.config→ msgspec. Deferred into the cold
path, where it was already being imported anyway.typing, 10ms.harlequin/__init__.pyimported it, and importing anyharlequin.*submodule executes that file. Every annotation there is a string under PEP
563, so the module now declaresTYPE_CHECKING = Falseitself 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:
- 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, apostCreateCommand) 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." - The audience is not only agents. A human running twenty
hsqlcalls 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, aSessionStarthook,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:hsqltakesCONN_STRpositionally, sohsql server foo.dbwould be ambiguous
in the same wayhsql catalogwas. --servetakes 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 writeHSQL_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:
--serveis a mode option;--sessionis 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--sessionis a plain option, and--servejoins 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:
-Pis the spelling the docs should show. A profile is already "how to connect to my
warehouse," andhsql --serve prod -P prodshould 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 declarativesecret=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-lifetimebounds it. --ssh-allow-reuseis already aCLI_ONLY_SSH_KEYSkey — 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 anhsql 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 asmerge_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 / --sessionThe 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() andNO_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. Otherwisehsql --session prod -P local run in a project would silently read the server'spyproject.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 runsharlequin.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 -1on 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 (manystdoutframes, then a terminatingexitframe) 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 parquetwrites through duckdb, not
through our byte stream, and routing it back through the socket would both double the
memory and breakoutput.py's "one binary stream,-oincluded" invariant. This is
also the design's clearest statement that client and server share a filesystem —
which aAF_UNIXsocket already implies.
4.4 Named sessions, not hashed connection identity
The tempting design derives the socket path from a hash of the connection identity, so a
client automatically finds the right server. Rejected:
- The hash would have to include connection-affecting options and exclude presentation
ones (--format,--limit,--color), and that partition is not written down anywhere
today — it would become a new thing to keep in step with every adapter's options. - Profiles resolve differently depending on cwd, so the same command in two directories
would hash differently, or the same hash would mean two databases. - Hashing secrets to name a file is a bad habit even when the hash is sound.
Instead: the name is the caller's, and the server owns the identity. hsql --serve prod -P prod connects however -P prod says to, and records what it resolved to. A client
that sends connection-affecting options gets one of two answers:
- identical to the server's → served;
- different → rejected, exit 2, naming the flag that differs and what the server has.
Not "reconnect with the new options" (that is a different session, and silently swapping
the database under a caller is the worst possible behavior), and not "ignore them"
(a client that typed -a postgres and got DuckDB has been lied to).
Socket path: $XDG_RUNTIME_DIR/hsql/<name>.sock, falling back to $TMPDIR/hsql-/
(or /tmp/hsql-/) when XDG_RUNTIME_DIR is unset — which is macOS, and also WSL2
without systemd, and also this container (§4.8). The fallback is the common path, not the
exotic one, and the directory is created 0700 and owned by the user either way. The name is
validated as [A-Za-z0-9_-]{1,64} so it cannot escape the directory.
Not platformdirs, which is what the rest of Harlequin uses for a user directory and
what the first draft named here. It costs ~22ms to import — about the whole of what a warm
invocation is supposed to cost — so the derivation is a dozen lines of os.path insession.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), sendscancel 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:
def fetchall(self) -> AutoBackendType | None:
try:
result = self.relation.to_arrow_table()
except duckdb.InterruptException:
return NoneIn 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, anddiagnostics.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_UNIXonly. 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, thenchmod, orumaskaround 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-zwould 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
lstatthe directory before trusting it.XDG_RUNTIME_DIRis
guaranteed by the system; theTMPDIRfallback is not, and on a shared host another
user can create/tmp/hsql-/first and put a listener atprod.sock. What a
client sends is argv, the cwd and piped stdin, and argv can carry aCONN_STRwith a
password in it, so the client refuses a directory it does not own privately — and the
server does the same, sinceos.mkdir(mode=0o700)does nothing to a directory that
already exists. SO_PEERCRED/LOCAL_PEERCREDuid 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/tmpmisconfiguration fail closed.--max-lifetime(default 8h) and--idle-timeout(default 30m,0to 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 — throughharlequin.redactand the declarativesecret=that shipped with #667, not through a
hand-written allowlist. - An SSH tunnel is part of the credential. A session opened with
--ssh-hostholds ansshprocess and a local listener for as long as it runs, and--max-lifetimeis 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, andHSQL_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/cdoes
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 PATHout of v1. XDG_RUNTIME_DIRis often unset under WSL2, since systemd is opt-in there. That is
exactly the fallback branch §4.4 specifies (a 0700hsql-directory underTMPDIR), 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 noAF_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 becamescripts/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:
{"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 declaredsecret=. 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, DuckDBPRAGMAs,
installed extensions. ASETin 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
BEGINand 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:
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.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_modeis the adapter's Auto/Manual setting — a plain attribute that
onlytoggle_transaction_mode()moves, which hsql never calls — so it says nothing
about whether a transaction is open, and abeginleft 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.hsql --session prod --session-resetrolls 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,
onehsqlinvocation 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:
- 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. - 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]
besideonline,use_cacheandpy12) 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 isno such tablecold 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. - 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 TUIone,
saying the client's three modules may not importclickor 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-autostartand it should inherit the idle timeout, not the max lifetime. - Windows. §4.8.
- TCP, remote servers, containers-talking-to-hosts.
AF_UNIXand a shared filesystem
are assumptions of §4.3's-ohandling 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-onlysessions, where "which connection" is unobservable. - Multiple databases per server. One session, one connection. Two databases is two
--serveprocesses, and they cost 200MB of RSS each at most, which is not the
constraint here. - A session for the TUI.
harlequin --session prodsounds 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 callsbuild_cli()and the samequery/outputcode, 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~/.sshand 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-statuscan reach it, because both open a second connection to a path that
is gone. UnderSIGINTan operator is present and knows what they just did; under--max-lifetimeit 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, aSessionStart-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.
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) andprotocol.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 onprotocol.py. Ships the import-linter contract,
the run-time guards, and the client's steps inscripts/cold_start.py.Two things moved out of it, both deliberately.
--sessionis not yet a click
option: the client intercepts it before click, so it works, but a flag--helpand--specadvertise 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, andsessionmust not be one, because the
decision is made before any config file is read. That is aCLI_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).The server: accept loop, one worker, the queue, the uid check, socket lifecycle.
hsql --serve NAME, the--sessionclick 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-resetlands 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.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: whetherHARLEQUIN_CONFIG_PATHtravels with the request. It does. It is the
caller's intent in the same way--config-pathis, 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 widensprotocol.FORWARDED_ENV_VARSpast "what--color autoneeds", 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-statusis 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,--specand the partition
still describe it, and so a cold invocation is refused rather than ignored; and it
joinsCLI_ONLY_SESSION_KEYS, because a profile could otherwise set it only for the
runs that never reach a session. The status'stransaction_modeis 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_sandexpires_in_s
are null until PR 5 can answer them, rather than absent, so the document's shape does
not change under a caller.Cancellation. Shipped.
SIGINT→ cancel frame →connection.cancel(), theIMPLEMENTS_CANCEL = Falsepath, and the DuckDBfetchall() -> Noneattribution.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 fromos.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, andcancel()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 onetry, or an
interrupt landing between them exits 130 having sent no cancel at all.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.Docs. Shipped.
/docs/hsql/sessionson harlequin.sh, written per §5.1, with theSessionStart-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 --helpmention 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--helpcannot
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, viaredact→config→ msgspec) andtyping
(10ms, viaharlequin/__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 declarativesecret=all shipped, and--timeout's implementation inhsql/timeout.pyis 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. --sessionwaits for PR 2 to become a click option (§9); PR 3 answered theHARLEQUIN_CONFIG_PATHquestion with "it travels."
Plans M1 Hsql Technical Plan (`docs/plans/m1-hsql-technical-plan.md`)
M1 Technical Plan — `hsql`, the headless CLI
Implementation plan for milestone M1 of Harlequin for agents.
That document is the product spec: what hsql is, why it's a separate command, and what
its contract with an agent looks like. This one is about how we build it, what has to be
refactored first, and in what order it can ship.
M1's scope, from the roadmap: second console script; extract the shared execution
core; import-linter rule and cold-start benchmark in CI; -c, -f, stdin, -o, -F +
shorthands, default --limit 500, truncation notices, --stats, exit codes,--on-error, --color/NO_COLOR, plain errors; unknown-option hint on harlequin;
the seeded "Headless & Agents" docs topic. Closes
#524.
Bottom line up front. A working prototype of the refactors below runs aselect … 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 andtree-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,
callsconnection.execute(q), appliescur.set_limit(...), collects cursors, postsQueriesExecuted.Harlequin._fetch_data(src/harlequin/app.py:1154) — drains each cursor withfetchall()+columns(), times the batch, postsResultsFetched.
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.335sThat 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 andharlequin_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 andNewCatalogItems — 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, andcolumn.pycontain zero references to Textual. The
imports are pyarrow and rich.- The only thing that pulls Textual is
textual_fastdatatable/__init__.pyimportingdata_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 byArrowBackend.from_parquet. Deferring it into that classmethod takes the import to
~140ms/183 modules. textual>=7.3.0is a required dist dependency. Nothing in the backend uses it. A
future leanhsqlinstall 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 andtree-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 bareprint()when a plug-in fails to import. Underhsql -F csv > out.csvthat lands in the CSV.harlequin/exception.py:68pretty_print_warning— usesrich.print, i.e. stdout.
Reached fromlocale_manager.pyandwindows_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 purepa.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 whathsql -c will do — config discovery, single-adapter load, connect, execute withset_limit(limit + 1), create_backend(data, max_rows=limit), write CSV to stdout:
220ms 238ms 242ms textual=False 306 modulesWith 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
- No execution core exists. It has to be extracted from the app before
hsqlcan
have one, or we ship two implementations on day one. - 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. - Building the CLI requires importing every installed adapter. The only unbounded
cost, and the only one that gets worse as the ecosystem grows. - Text layout doesn't exist, and is coupled to a widget where it does. The TUI
renders into a DataTable;export.pywrites files but only from aResultsTable.
Nothing arranges a result astable,markdownorverticaltext.
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 withThe one-line statement of the architecture, which is also a testable rule:
stdout is written only by
harlequin.layoutandharlequin.export. stderr is
written only byharlequin.hsql.diagnostics.
3.1 `harlequin.query` — the execution core, over the existing backend
@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 apa.Table (duckdb), a RecordBatch, a Sequence[Iterable] (sqlite), aMapping[str, Sequence], or a polars or pandas frame. create_backend() already
normalizes all of those, it is the normalization the TUI uses, and per §1.3 it needs no
Textual to run. Writing a second normalizer for hsql would put the most drift-prone
code in the codebase — "what counts as a row, what counts as null, how does a nested
struct come out" — in two places, and the disagreement would surface as hsql and the
TUI showing different data for the same query. That is the worst possible place for
these two front doors to diverge.
Superseded, after M1 shipped. The collision described below was resolved by
#1026: the display cap has its own
key,viewer_max_rows,limitis the hard fetch limit in both commands, and the text
layouts grew a soft cap of their own (--display-rows).harlequin --limitnow 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. SeeAGENTS.mdfor 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'smax_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.
def find_separators(
text: str,
) -> list[Point]: ... # (row, character column) past each ";"
def split(text: str) -> list[Statement]: ... # separators -> trimmed statementstree-sitter and tree-sitter-sql become explicit, required dependencies ofharlequin (Ted's call). Both are installed for everyone today, transitively viatextual[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 theharlequin.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 callsfind_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 meanshsql 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 isheader=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 andna_rep, so the same four flags mean the same thing in every format.
3.4 `harlequin.plugins` — naming an adapter without importing it
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]]: ... # existingadapter_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:
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. */
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 JSONTwo things worth stating because they're easy to get wrong:
- The truncation notice fires under
-t.-tsuppresses 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 inharlequin.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 intoArrowBackend.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 andtree-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. Extendexport.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 oftest_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 meanhsql -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
hsqlmodule graph must
not containtextualorquestionary. Plus subprocess tests asserting"textual" not in sys.modulesafter importing the headless core, and the same test
upstream after importingtextual_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:
CliRunnertests forhsql, following the existingtest_cli.pypatterns 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 tableand-F csvcan be asserted to
agree cell for cell. text_columns()agreement: for that fixture, the strings-F tableprints are the
same strings-F csvwrites.- 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
underLC_ALL=de_DE.UTF-8, the regression test for §3.3's locale trap. -o PATHand> PATHproduce 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 implementset_limitdifferently enough to matter. - The tricky-SQL corpus against
statements.split()and, throughselected_queries(),
the editor — same fixtures, one splitter underneath. Includes non-ASCII before a
semicolon with no following space (the §3.2 bug, which fails onmain) 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'smax_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:
--limitthere 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-sitterandtree-sitter-sqlbecome required dependencies. One splitter, no
fallback, the troubleshooting page retired (§3.2). Accepted cost: no install on
platforms without a tree-sitter wheel.--helpis adapter-agnostic, listing adapter names and pointing at--help -a X
for connection options (§3.6). Ted's design; better than what I proposed.--resultdefaults toall, 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 oftextual-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 --helpis "unavoidable and shared."
It's avoidable.hsql --helplists 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 + 1fetch 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 is500 of >500-- strictly greater, which the limit+1 fetch proves and
the conventional500+would understate -- and only-l 0yields an exact count. (The TUI can say100,000 of 3,412,887because its limit is a display cap over a full fetch; see §3.1.) - §5 lists
--limitas though it means the same thing in both commands. It doesn't:
soft display cap in the TUI, hard fetch limit inhsql. Worth one explicit sentence in
the docs rather than letting a reader assume they match. - §5's
--resultdefault is format-dependent (allfor text,lastfor data), which
makes the same command emit different things depending on-F. It'salleverywhere,
with an error rather than silent corruption where a format can't hold more than one
result set (§6).
Plans M2 Hsql Technical Plan (`docs/plans/m2-hsql-technical-plan.md`)
M2 Technical Plan — self-description and safety
Implementation plan for milestone M2 of Harlequin for agents,
following the M1 technical plan. The product plan says whathsql is for; M1 built the command and the execution core under it. This one is about
letting an agent learn a database before it queries one, and about the flags that let a
human hand an agent a warehouse credential.
M2's scope, from the roadmap: catalog (one level below the match), info --json,spec --json, config validate/show/schema/init, capability flags, the
secret option type and declarative redaction
(#667), env interpolation
(#898), --read-only, --timeout, and
a published JSON Schema. find + implements_catalog_search is promoted out of the stretch
column and ships with implementations for both in-tree adapters; optional fetch_descendants
stays future work.
Cut in review (§7 has the reasoning for each): describe, fmt, --dry-run,--single-transaction (which M1 §7 had deferred to M2), --password-stdin, recursive--depth, the node budget, child counts, round-trip counting, and the compact and tree
layouts.
Where M1 actually got to. PRs 1–6 shipped: the import-hygiene work, the statement
splitter and execution core, export/layout, the shared profile-merge and one-adapter
loading, hsql itself, and the two commands pointing at each other. PR 7 (the docs topic,
against tconbeer/harlequin-web) is in flight. PR 8, the agent eval suite, is not being
done — so M2 has no automated measure of "an agent used the right flag first try," and the
guards below are all deterministic ones.
Bottom line up front. M2 needs no new execution path, no new output format family, and
no second config parser: catalog listings are result sets, so --format, -o, -t/-A,--display-rows and --stats already apply to them, and the row cap that keeps a 400-row
listing readable is the one M1 already shipped. What M2 does need is five additive members
on the adapter contract — four fields and one method — because the features that would
otherwise be built on guesswork are unimplementable without them: the catalog knows a column
is ## but not that it is DECIMAL(18,2), no adapter can say whether it enforces read-only,
and none can answer "where does orders live" without a walk. Those are measured,
below, not feared.
On the shape of the CLI. Every capability here is a mode option on the one hsql
command — --catalog, --find, --info, --spec, --config MODE — not a subcommand
(Ted's call). It matches harlequin --config, and it removes an ambiguity subcommands
would have created: hsql takes CONN_STR positionally, so a database file namedcatalog 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 767msM1'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 callfetch_children() on mydb.analytics.orders you need that item object, which onlySchemaCatalogItem.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 onefetch_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() selectscolumn_name, data_type from information_schema.columns and then doestype_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, becauseHarlequinSqliteConnection.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 andgetattr(opt, "default", None) — duck-typing at a distance, which is what a realto_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 aTextOption, 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, whereps 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:
>>> DuckDbAdapter(conn_str=(":memory:",), reed_only=True, dbnmae="warehouse")
constructed fine | read_only = FalseThat 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:
# ~/.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 thatconfig_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: thehsql does not reach the TUI import-linter contract lists them, andtests/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.hsqlmust never import it, becausehsqlmust
never prompt (§3.7).--config initis the non-interactive path, and the wizard stays
where it is.tomlkit, 49ms alone and +8ms on top ofharlequin.hsql.cli. Only--config init
writes a file, so only--config initpays 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 ownread-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 aHarlequinTransactionMode(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: 702msDuckDbCursor.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 into_arrow_table()producesterminate called without an active exceptionand 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
- 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. - 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. hsqlgrows 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.- A timeout cannot trust the adapter to report itself. §1.9.
hsqlhas to attribute
the cancellation it caused, because the adapter can't. - Secrets leak through four new mouths at once.
--info,--spec,--config showand
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.pyM1's rule stands and now covers four modes as well as the run form:
stdout is written only by
harlequin.layoutandharlequin.export. stderr is written
only byharlequin.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.
--helpimports no adapter;--specand--configimport no database driver;--infoopens no connection;--catalogfetches
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 | initWhy 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 typeshsql --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. Sohsql --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
@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:
@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 aResultSet — 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 adescribe was going to print. There is no second mode for it (§7).
3.4 Capabilities are declared, not detected
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 postgresexits 2 withpostgres 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-onlyexists so a human can hand over a credential, and a flag that
no-ops is worse than one that is absent. - Core still catches
NotImplementedErrorat 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 aboutimplements_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.
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 — itsADAPTER_OPTIONS say exactly which further keys are legal, so the selected profile can be
validated in full:
declared = {sluggify(o.name) for o in adapter_cls.ADAPTER_OPTIONS}
allowed = core_keys | TUI_ONLY_KEYS | declaredThis 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.pytells 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 = 5432andport = "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:
@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
HarlequinConfigErrornaming 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_configstays raw,
becauseconfig_wizardreads it and writes it back (§1.6): interpolating there would bake
a resolved secret into the user's file on their nextharlequin --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 stoppedharlequinfrom
starting under any profile. Resolving in_select_profile()instead is the ruledefault_profilealready follows (a name is raised where it is used), and it costs the
merge'sProvenance— 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 showand 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 — anddefault_profileand thekeymapstables are not interpolated at all,
which nothing needs and which is what buys the laziness.--config validateresolves 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 hasharlequin-databricks and what its options are. With a declared model this ismsgspec.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 athttps://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 ofharlequin --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 ownread-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):
- Refuse at parse time if
IMPLEMENTS_CANCELis 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. - Run the statements on a worker thread; the main thread waits on the deadline.
- On expiry:
connection.cancel(), then join for a short grace period. hsqlattributes 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 ishsql: error: timed out after 30s, and the exit code is 4 — the numberExitCode.TIMEOUThas been reserving since M1.- If the grace period expires too, flush both streams and
os._exit(ExitCode.TIMEOUT).
Plainsys.exitwith 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 tomode. 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
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 constructhsql --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_stris DSN-aware — and
#354 is evidence people really do put
passwords there. - A driver exception that echoes a DSN does not pass through our option layer, so
redact_textis 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 acustomer_id — and it cannot be a walk. It wants an optional capability:
class HarlequinConnection:
def search_catalog(
self, term: str, kind: Literal["tables", "columns", "all"]
) -> list[CatalogItem]: ... # raises NotImplementedErrorOne information_schema-style query where the adapter can serve it, an explicit "not
supported by this adapter, try --catalog" where it can't, surfaced throughIMPLEMENTS_CATALOG_SEARCH in --info.
fetch_descendants(depth) on InteractiveCatalogItem, defaulting to a fetch_children
walk, is the same bet at the other end, and it is the precondition for recursion ever
coming back (§3.2): it is what turns §1.1's 403 round trips into one for an adapter that
can express the whole level in a single query, and it improves the IDE's expand-all path
too. A --depth flag on top of a walking default would just be the cliff again with a
prettier name, which is why it waits for this rather than shipping beside it.
On the other idea from review — concurrency. Opening several connections to parallelize a
recursive walk would help wall-clock time and nothing else, and it costs more than it looks:
the adapter contract says nothing about a connection being usable from two threads, nor does
it offer a way to open a second one from an existing connection, so hsql would have to
re-connect() (re-authenticating N times) and hope. It also multiplies load on the database
for a call the agent should not be making in bulk. Worth revisiting if fetch_descendants
lands and is still too slow; not worth it as the first answer.
4. Sequencing
Three releases, config and self-description first (Ted's call). That group fixes bugs
that exist today — the merge in #1040, and error messages that can't name the file they came
from — and needs no adapter change at all, so it can ship while the catalog's contract
addition is still being rolled out. The catalog follows, with search implemented for both
in-tree adapters rather than left as a stretch. Safety is last, because it is the only group
whose flags are false on most adapters until the ecosystem catches up.
PRs 15 and 16 were pulled forward into Release A (Ted's call, taken after PR 7). They are
the two that never needed the rest of Release C: neither declares a capability, so neither
waits on the ecosystem. They belong with the self-description modes instead — the whole of
what --config show, --info and --spec do is print a user's own config back, and the
release that adds three ways to print a profile should not be the release before the one that
learns which of its values must not be printed. ${VAR} follows redaction because it is the
other half of the same answer: redaction keeps a secret out of Harlequin's output, and
interpolation keeps it out of the config file to begin with. They keep their numbers, which
the rest of this document refers to; only the release they ship in changed.
Numbering assumes M1's remaining work releases as 2.9.
Release A — config and self-description (2.10)
PR 1 — Validate per file, then merge per profile. The reordering, the per-profile merge, the
second pass over the selected profile's adapter options, and the errors that can finally name
a file. This is where msgspec arrives as a dependency, or doesn't. Fixes
#1040, which is a hard stop for anyone
whose home config sets default_profile and who then adds a project-local file. Behavior
change, Bug Fixes entry, and the failing case as its test.
PR 2 — --config show and --config list-profiles. Provenance per key, and the short
list of names that answers "what can I pass to -P".
PR 3 — --config validate. The machine channel over PR 1's per-file diagnostics.
PR 4 — AbstractOption.to_dict() and --spec. Concrete, never abstract, with a base
implementation that works for a subclass that predates it. --spec covers hsql's own options
and every installed adapter's; -a NAME narrows it to one. (It cannot cover harlequin's
own flags: hsql may not import harlequin.cli, which builds the IDE's command. Say so in
the output rather than pretending the list is exhaustive.)
PR 5 — --info. Versions, platform, discovered config files in precedence order, active
profile, and declared capabilities per adapter. No connection.
PR 6 — --config schema and the packaged base schema. Depends on PR 4's serialization.
PR 7 — --config init. tomlkit deferred; no questionary.
PR 15 — Secret options and redaction. secret= on options, harlequin.redact, DSN
redaction, masked wizard input. Closes #667's structural half. Pulled forward from Release C:
every mode Release A adds prints a profile, and this is what decides what they print.
PR 16 — ${VAR} interpolation. Closes #898. Pulled forward with PR 15 — a caller told to
keep a token out of their config file needs the spelling that replaces it in the same release.
PR 8 — Docs. The config spec page, the self-description pages, the site's JSON Schema
route, and — arriving with PRs 15 and 16 rather than in PR 17 — the secrets guidance and the${VAR} spelling.
If the model question in §3.5 resolves toward msgspec, it lands in PR 1 — the declaration
is what PR 1's validation and PR 6's schema both read, and retrofitting it later means
writing both twice.
Release B — the catalog (2.11)
PR 9 — Mode options, and --catalog at one level. The mode flags and their mutual
exclusion, --path, and which modes attach adapter options. harlequin.navigate with path
resolution and single-level listing. Listings as result sets, query_name included, and thetext_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 saytotal 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 (aharlequin-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 andIMPLEMENTS_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 notomlkit, noquestionaryand (still) nosqlfmt;hsql --helpimports no adapter;hsql -a sqlite --catalogimports noduckdb. Each is a subprocess readingsys.modules, and each
deferral has a matchingignore_importsentry with a comment at the import site. - A fake adapter that declares nothing proves every refusal path:
--read-only,--timeoutand--findeach exit 2 with a message naming the adapter, before any
connection is opened. - Golden files for
--catalog,--info,--specand--config showin
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 notype_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
thatharlequin --configon a file containing${MYPASSWORD}writes${MYPASSWORD}back. - The merge fix gets the failing case as its test: a home file with
default_profileand
two profiles, a cwd file with a third, asserting all three survive and the default still
resolves. That combination raisesHarlequinConfigErroronmaintoday. - Timeout tests use a fake adapter, not a slow query:
executeblocks on an event,cancelsets 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,\non every platform, and unaffected byLC_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_KEYSkey and a
declared adapter option are each accepted in a profile, and a misspelling of one is
rejected naming the adapter — withport = 5432andport = "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-cand not a value on--catalogitself
(§3.1).-cwould mean SQL in one invocation and an identifier in another;--catalog PATHwould take its value greedily and readhsql --catalog mydb.dbas a path against an
in-memory database.One level, no
--depth, no node budget (§3.2). Recursion returns withfetch_descendants(), which is what would make it one round trip instead of 403.No
child_countfield (§1.3). A count is only knowable by fetching, andlen(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.relationis a describe.No new layouts (Ted's call, §3.3).
treehas no tree to draw over one level, andcompactonly reads asparent(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-onlyis 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).--infodoes not connect, and--specand--configdo 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).
Configis
the TypedDict and the model, so there is one declaration of what a config file may say
andmsgspec.convert(raw, Config)is the whole of pass 1; pass 2 builds a struct from the
adapter's ownADAPTER_OPTIONSand parses the profile into it, so an option arrives as the
type its adapter declared.Configis aStructwithforbid_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 ofmainon 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 wheredifflibsays did you meanread_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
--infoand--specare real, or wanting Pydantic'sextra="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 onhsql -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
Profilemodel with
declared fields turns{"adapter": "postgres", "dbname": "warehouse"}into a profile with
nodbname, which is the value the adapter needed. msgspec and cattrs both drop unknown
keys (their only alternative is to reject them), so under either,profilesstaysdict[str, dict[str, Any]]with a model validating the keys core owns and the raw dict
carried alongside. Only Pydantic'sextra="allow"preserves them — the one place it is
genuinely the better tool. - Two of the three would reintroduce a bug we already guard.
limit = truestructures to1under Pydantic and cattrs.parse_row_count()rejects it today, with a comment saying
why: a bool is an int in Python, andtrueis 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 schemais 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.ordersalready returns the columns oforderswith 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 runsqlfmtitself, 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 implementsvalidate_sqlat all, and even there
it is a parse check rather than an existence check: measured on this checkout,select * from no_such_tableandinsert 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 aPREPAREpass 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 withfetch_descendants()rather than ahead of it. Round-trip counting in--statsgoes with them: it existed to make a walk's cost visible after the fact. - The
compactandtreelayouts (§3.3). One level is a flat list, sotreehas
nothing to draw, andcompact'sparent(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 csvare 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 puttingbeginandcommitin 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 onharlequin (#667's other half); the agent eval suite (dropped from M1); the site'sllms.txt, raw markdown routes and Docs API (M3). Publishing the JSON Schema atharlequin.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 makingtextual 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 matchesharlequin --configand
avoids ruling on whetherhsql catalogmeans the verb or the database file of that name. - §6 says
hsql catalog mydb.analytics.ordersis "exactly onefetch_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 Nis 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
describeiscatalogon 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 compactis both unimplementable and ungeneralizable.CatalogItem
carries a 1–3 charactertype_label, so the type an agent needs is not in the contract at
all — hencetype_name, which the in-tree adapters already fetch and discard (§1.3). And
the format'sparent(child TYPE, …)shape only reads for a relation and its columns; a
database's schemas are not a signature. There is nocompactformat;-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_sqland raisesNotImplementedErrorfrom 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 letshsql --infoanswer while the database is
unreachable (§1.4). - §5's
--timeoutneedshsqlto 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-transactionand--password-stdin, and §6'sfmt, are
cut (§7), so the roadmap's M2 row and §5's flag list both overstate what the milestone
delivers.--single-transactionis 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 frombegin/commitin 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 wholeprofiles
table while leaving itsdefault_profilebehind, so both commands refuse to start (§1.6).
Verified against the pre-refactorconfig.pyas well: this is long-standing behavior, not
something thetomllibchange introduced. - §7's
${VAR}interpolation needs to be a read-path transform, becauseharlequin --configreads the config and writes it back — interpolating too early writes
the resolved secret into the user's file (§1.6).
Plans M3 Hsql Technical Plan (`docs/plans/m3-hsql-technical-plan.md`)
M3 Technical Plan — docs a machine can read, and the skill
Implementation plan for milestone M3 of Harlequin for agents,
following M1 and M2. M1 built
the command, M2 taught it to describe itself. This one is about everything an agent reads
before it runs hsql: the documentation, and the skill that tells it which flag to reach
for.
M3's scope, from the roadmap: llms.txt, llms-full.txt, raw .md routes, Docs API v1,
copy-as-markdown, homepage positioning, and AGENTS.md — Workstream D. The Harlequin skill
(E1) is pulled forward from M4 (Ted's call). The rest of M4 — hsql open, JSONL history,
"Copy CLI command", the external-command hook — stays in M4.
Where M2 actually got to. PRs 1–12, 15 and 16 shipped: the per-file/per-profile config
work, the five self-description modes, secrets and ${VAR}, --catalog, type_name and--catalog-search. PRs 13 (--read-only and the capability declarations), 14 (--timeout)
and 17 (the safety docs) are in flight and unshipped, which is a real dependency here and not
a footnote: two of the nine things the product plan says the skill should teach are flags that
do not exist yet (§1.9).
Two repos, and most of the work is in the other one. tconbeer/harlequin-web is wherellms.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:
- Docs written for machines have to be true, and today they are not.
hsql --parquet
andhsql --resultsare 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 schemahsql --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. - 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}>whosesrcis 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). - 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 ofhsqlthat is installed;
and it teaches--info/--spec/--help -arather 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 ontconbeer/harlequin-web at 31b2051, built with pnpm 10.33 / Node 22.22 and served frompnpm 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 throughsrc/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.6xThe 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 andllms-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 |
{ / } 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&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
SCHEMA_ID = "https://harlequin.sh/schemas/config/v1.json"/* Detailed source-code truncated for AI context efficiency. */
"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. */
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 repoPrinting 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:
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.A marketplace entry in this repo. A plugin that ships exactly one skill may put
SKILL.mdat the plugin root rather than underskills/, and a marketplace entry'ssourcemay be a path inside its own repository. So.claude-plugin/marketplace.jsonat
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.jsoninside it carries the name, description and version; it ships
in the wheel too, at a few hundred bytes. Then:text/plugin marketplace add tconbeer/harlequin # once /plugin install hsql@harlequinclaude plugin validate ./src/harlequin/hsql/skillis a real command, so this tier comes
with a CI guard rather than a promise.The community marketplace.
anthropics/claude-plugins-communityis 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-approvehsql -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:
- Ask before you assume.
hsql --infofor versions, config files, the active profile and
what each adapter supports;hsql --help -a NAMEfor one adapter's connection options;hsql --specwhen a machine needs the option list rather than a human. - 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. - Orient in the catalog before writing SQL.
hsql --catalog,--path db.schema,--path db.schema.tablefor columns,--catalog-search TERMwhen you don't know where
something lives — and check--infofirst, because searching is a capability not every
adapter has. - Run it.
-cfor a statement,-ffor a file (-f -for stdin),--resultto pick
which result set prints,--on-errorto pick what happens after a failure. - Pick a format on purpose.
-tAcfor one value into a shell variable;--csvfor a
pipe;--markdownwhen the output is going into your own reply;--format parquet -ofor
anything large.--formattakes the name; only csv, json, jsonl, markdown and vertical
have shorthands. - The row limit is real. 500 by default.
--statsputs{"truncated": …}on stderr —
read it, and never2>/dev/null, because that is where truncation warnings and errors both
go. - 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. - Ask before you write. Prefer
--read-onlywhere the adapter declares it (--info
again); say what a DDL or DML statement will do before running it. - 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 openharlequin -P— same profile, same adapter, same engine.
What it deliberately does not contain: the adapter list, any adapter's options, the full
format list, the option table, or anything about the config file's schema. All five are
one command away, and all five are what makes a skill rot.
Section 9 is the one nobody else would write, and it is also the one M4 improves: withhsql 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; checktruncated 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 inskill-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 andrunning.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 inpackaging/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 ontest_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 --skillis byte-identical to the packaged file, and writing it with-oproduces
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 --skillimports no adapter, no tomlkit, no questionary. A subprocess readingsys.modules, like every other import-hygiene test, with a matchingignore_importsentry.- The skill's frontmatter uses only the six spec fields, parses as YAML, and carries a
non-emptydescription; 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 validatepasses onsrc/harlequin/hsql/skill, and the marketplace entry
names a source directory that exists. In CI where theclaudeCLI is available; skipped,
not silently passed, where it is not.- The generated reference regenerates identically.
test_cli_reference.pyruns the
generator and compares to the committed file, exactly astest_config_schema.pydoes. - 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
<Capitalizedtag, 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 thex/indexnormalization, 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.txtlists exactly as many pages assrc/docsholds, so a new page cannot be
silently missing from the index an agent reads first.- The
.mdroute andllms-full.txtagree 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.jsonis 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,--specand--help -ainstead. 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 callbuildCorpus(). A second markdown-producing path is the same mistake as a second
normalizer inquery.fetch(). llms.txtis the front door;llms-full.txtis the fallback. 29,000 tokens is not an
opening move..mdsuffix routes, notAcceptnegotiation (§3.3)./api/docsis 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_atin 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.mdstays 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-toolsnames the introspection modes and neverBash(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-webPR, or does a human run the script?
§3.4 assumes automation, which needs a token with write access to the other repo. Amake publish-artifactsthat 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). Themcp.mdpage 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.txtplus 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. Themanifest.jsonversion 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 dayreferences/earns its place, and not
before. - Fixing the site's sidebar API shape (§1.6's
"/src"), and thegetting-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
andcatalog --depth 2were all written before M2 settled on mode options. The real
spellings arehsql --infoandhsql --spec(both JSON, always), and--catalogwith--path, plus--catalog-search TERM.--depthwas cut from M2 outright (M2 §7), so
"orient withcatalog --depth 2" describes a flag that will not exist untilfetch_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 -ofrom 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#hsqlblock;AGENTS.mdis in bothharlequinandharlequin-web. What is left
is a one-word correction on the homepage andAGENTS.mdin the adapter template. The
roadmap's M3 row overstates the remaining work by two of its seven items. - §8's D1 treats
llms-full.txtas the primary affordance — "an agent fetches one URL and
knows everything." Measured, that URL is ~29,000 tokens.llms.txtplus per-page.mdis
the primary path;llms-full.txtis 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.txtplus 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.jsonsince 2.10, and that
URL 404s. Publishing generated artifacts is a Workstream D deliverable that the roadmap
never listed (§1.5, §3.4). - §8 assumes the docs are correct, and they are not. Two flags documented in three places
exit 2, two internal links 404 (§1.4), and the page written for agents renders&lbrace
where{belongs (§1.3).
The workstream's first deliverable is a check, not a corpus — otherwise M3 publishes the
same errors through four more channels.
Plans M4 Hsql Technical Plan (`docs/plans/m4-hsql-technical-plan.md`)
M4 Technical Plan — the handoff, and the two hooks
Implementation plan for milestone M4 of Harlequin for agents,
following M1, M2 and
M3. M1 built the command, M2 taught it to describe itself, M3
wrote the docs and the skill an agent reads before it runs. This one is Workstream F: what
happens when the agent and the human need to hand work to each other.
M4's scope, from the roadmap: the skill, hsql open, JSONL history, "Copy CLI command",
and the external-command hook. The skill (E1) shipped in M3 — it is in the wheel, prints
from hsql --skill, and installs as a plugin — so what is left is the four handoff features.
Two of those turn out to be three: the external-command hook covers two requests that share
almost nothing, and this plan splits them (§6.1). And "JSONL history" turns out to name a
storage format that the History screen's missing search rules out (§6.4).
The issues M4 closes. #1102, external
editor support, was opened while this plan was being written and carries a reference
implementation from Posting. #952,
natural language to SQL, is closed not planned — "I won't be adding this to Harlequin" — and
the command hook is the answer that stays true to that: Harlequin runs the tool the user
already trusts, with the user's own credentials, and embeds no model (§8).
#850, the History screen's UI, and
#429, search in that screen, ride along
with the release that changes what it reads — and #429 is what decides how the history is
stored at all (§3.2, §3.4).
Three releases, and the editor and the hooks go first (Ted's call). Nothing in this
milestone depends on anything else in it, so the order is a product decision: A is the editor
and the hooks, B is query history and search, C is the handoff (§4).
Bottom line up front. Three claims, each measured below.
- The handoff between the two commands is a process boundary, and everything cheap about
it is on the far side of one.hsql --opencannot put a buffer in front of a human
in-process:harlequin.editor_cacheimports Textual — 341 modules, +164ms over a bare
interpreter, against a wholehsql --versionof 118ms — and it is rewritten whole ataction_quit, so a second writer would race a running IDE for the file that holds every
open buffer (§1.1).--openexecs 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). - 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, akill, or a container that
goes away loses the session — and it lives inharlequin.history, which imports rich,
which is on the headless FORBIDDEN list and costs +46ms on a 118ms command (§1.2, §1.3).
The store both commands can write is a SQLite database, not the JSONL file the roadmap
names: with #429 in scope, searching
100,000 records costs 396ms as a file scan and 35ms as alike, and 400ms per
keystroke is not a filter anyone can type into.sqlite3is stdlib, imports cheaper thanjson(+4.0ms against +7.6ms), and costs 0.020ms a row to write (§3.2). - 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 onCatalogCacheLoaded 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. hsqlwrites 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 importingharlequin.historyto unpickle
the dataclass, which imports rich.
It also cannot be searched. The History screen is an OptionList of at most 500 records with
no filter input, so "what was that query I ran last week" is answered by scrolling —
#429, open since January 2024. Search is
the requirement that decides the storage (§3.2), which is why it belongs in this milestone
rather than after it.
And the screen that shows it has other problems, filed as
#850. HistoryScreen subclasses Screen,
not ModalScreen — and ModalScreen is the one whose "bindings take precedence over the
App's", so every app-level key is live behind a screen whose job is to pick one query. Its
preview pane is a TextEditor with read_only=True, which stops edits but not focus: Tab or a
click puts a cursor in a query the user cannot change. Ted's reading of the issue is that the
interactivity of the preview is the problem, not the missing key list.
1.3 `harlequin.history` is a rendering, and headless code may not import it
QueryExecution.__rich__ builds a Group of a Columns and a Text: the History screen's
row, in the dataclass that holds the record. tests/unit_tests/test_import_hygiene.py listsrich 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 throughtextual_fastdatatable before 0.17.1 deferred it.
Measured, that guard is not ceremony: harlequin.history costs +46ms, which is 39% of a wholehsql --version invocation:
hsql --version 114-119ms
harlequin --version 621-636msharlequin.catalog_cache imports harlequin.history, so it carries rich too — which meansget_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, plusprofile_name, connection_hash, and the TUI's own settings. conn_str is consumed incli.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_mountThe 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'sApp.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_suspendis True on the Linux and Windows drivers and
False on the base driver, which the headless driver inherits. Measured insiderun_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 . 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 sayhsql --open -f draft.sql -P prod instead, and M3's §7 already names it as the paragraph M4
rewrites.
2. The obstacles, stated plainly
- 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. - The store that both commands write has to be below rich. Not
harlequin.history, notcatalog_cache, and not a pickle of a dataclass whose__repr__is a Rich renderable. - 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. - The app does not remember how it was started, and half of what it would need to
remember is secret. - 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. - A config file can now ask Harlequin to execute a shell command, and config files arrive
from directories the user did not audit. - 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 modeinvocation.py and query_log.py are importable by hsql (stdlib plus platformdirs andharlequin.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 underTYPE_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:
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 prodThe 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:/harlequin/open/YYYYmmdd-HHMMSS-.sql, pruned of anything older than
seven days on each invocation. State, not cache: an unsaved draft is not disposable. A query
that came from -f is passed by its own path — the IDE copies text into a buffer and never
writes back, so the human's file is not touched either way.
3.2 `harlequin.query_log` — one store, two writers, and why it is SQLite
One database, at /harlequin/history.db, one row per statement:
create table queries (
id integer primary key, -- insertion order, which is also newest-last
run_at text not null, -- UTC, ISO-8601
program text not null, -- 'hsql' or 'harlequin'
connection text, -- the id the caches key on
profile text,
adapter text,
sql text not null,
status text not null, -- 'ok' | 'error' | 'canceled'
rows integer,
truncated integer,
elapsed_ms real,
error text
);
create index queries_connection_at on queries (connection, id desc);Search is what decides the format — §1.2 and
#429. A tail read is bounded by
construction — the newest 500 — so an append-only file serves it well. Search is the
opposite: its whole value is that it reaches queries you ran weeks ago, which means reading
everything. Measured over 100,000 records (52 MB of JSONL, 45 MB of
SQLite, both warm in page cache):
| JSON Lines | SQLite | |
|---|---|---|
| newest 500 for one connection | 8.2ms (hand-written reverse scan) | 16.7ms (order by id desc limit 500) |
| search all of it, newest 500 matches | 396.4ms (full scan + parse) | 34.9ms (sql like ?) |
| one record written | 0.006ms (os.write, O_APPEND) |
0.020ms (WAL, synchronous=normal) |
| import cost | +7.6ms (json) |
+4.0ms (sqlite3) |
The tail row is a wash and the write row is 14µs on a query that cannot take less than a
millisecond. The search row is the milestone: 400ms per keystroke is not a filter anyone can
type into, and it is 400ms only because the file was in cache. sqlite3 is cheaper to
import than json, is stdlib, and is already the module harlequin_sqlite drives.
Pragmas are part of the design, not tuning. journal_mode=wal and synchronous=normal
are what make a write 0.020ms; the defaults measure 0.678ms, a 34× difference that would show
up on a script of a thousand statements. busy_timeout=5000 is what makes concurrent writers
a non-event: 8 processes × 200 inserts produced 1,600 rows, 0 errors, in 0.15s wall. WAL needs
shared memory, which a network home directory does not have, so a failure to enter WAL falls
back to the default journal — still 0.678ms, still correct.
A schema change is an alter table, not a new file. pragma user_version plus an ordered
list of migrations, which is the thing the pickle caches cannot do: CACHE_VERSION throws the
old data away, and history is the one store where that is a loss rather than a re-fetch.
Everything else about the record is unchanged by the format:
sqlgoes throughredact.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 outsideredact:redact_text()masks
every value this process was told to hide. Which means the IDE has to start callinghide_secrets_in()(§1.4) — one line incli.py, and the debug screen gets more honest for
free.connectionis the same id the caches key on, so the History screen can show this
database's queries and--historycan filter to them.get_connection_hash()moves out ofcatalog_cacheintoquery_log(it is a hash of a dict; it never needed rich), andcatalog_cacheimports it from there.statusisok,errororcanceled— 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 arename: 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 whatquery.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 whoseconnection_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.
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 forconnection_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,-aor aCONN_STRnarrows to that connection;
with none of them, the mode reports every connection, because "I typedhsql --historyand
got someone else's idea of the default database" is the surprising answer. Theprofileandadaptercolumns are what tell them apart. - Newest first, which is what every shell means by history, and what makes
--limitmean
"the most recent N" rather than "the oldest N".--limitis 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-1for all. - Columns:
run_at,program,profile,adapter,status,rows,elapsed_ms,sql. sqlis folded to one line, in every format. Two invariants force it and one fact
makes it harmless:layout.pypads by terminal cells and has no concept of a cell that
spans rows, and--format tableand--format csvagree 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 TERMis its own mode, exactly as--catalog-searchis its own mode
beside--catalog: same store, same columns, same--limit, one addedsql like '%TERM%'.
M2 cut--findand settled on--catalog-search, so a caller who learned that spelling
guesses this one, and_one_mode()already refuses two modes in one invocation. It is
#429's headless half, it is one clause
rather than a reader, and it is the flag that would have been unaffordable over a file
(§3.2). Unlike--catalog-searchit 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 20costs 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:rowsarrivesint64,elapsed_msdouble, the rest strings. So--jsonemits"rows":42,"elapsed_ms":12.5rather than"rows":"42", which is the
difference between a record an agent can sum and one it has to re-parse. The price is thattext_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 prints12.5wherestr()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.historyis removed andCACHE_VERSIONgoes 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 andprogram = '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:
@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 isto_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 500Quoting 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 toviornotepad: launching an editor the user never named is the one
outcome nobody asked for. The refusal names both spellings. $EDITORand$VISUALneed no confirmation. The environment is the user's own shell
session (Ted's call, and the reason). A config-definededitoris 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 — thegit rebase -ishape that #767 identified and that Posting
implements. with app.suspend():, on the main thread. Wrapped soSuspendNotSupportedandOSError
become an error modal rather than a traceback, and behindharlequin.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/:cqconvention, 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 callsreplace()over the whole document — so the round trip
is oneCtrl+Zaway, 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.
[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 postsExternalCommandFinished(name, stdout, stderr, code)and an@onhandler 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_ADAPTERandHARLEQUIN_CONN_STRin its environment, and that is the whole of
the "and optionally the catalog" the product plan wanted: a hook that needs the schema runshsql -P "$HARLEQUIN_PROFILE" --catalogor--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. timeoutbounds 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 incommand; a shell
string would mean two quoting rules (POSIX andcmd.exe) for a feature whose whole value is
running something the user already has.commandtakes a list, or a string thatshlex
splits. hsqlruns no hooks, ever. A headless command that executes shell commands out of a
config file it found in$CWDis a supply-chain hazard in CI.hsqlreads the table for--config validateand ignores it otherwise.
3.8 Trust: what a config file may make Harlequin execute
Two rules, and neither is a preference the config file can set for itself.
Rule 1 — provenance. [commands] and a top-level editor are honored only from a config
file the user named or keeps: an explicit --config-path, the user config dir, or home. A
definition found in ./.harlequin.toml or ./pyproject.toml is ignored, with one
notification naming the file and the key. Merging already tracks which file supplied each key
(Provenance, M2 §3.6), so this is a check at the point of use, not a second pass.
Rule 2 — consent, per definition. Before a command runs for the first time, a modal shows
the exact argv, the config file it came from, and three choices — Run once / Always allow /
Cancel — with Cancel focused. "Always allow" appends a record to/harlequin/trusted_commands.json:
{"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 withrun_command('<name>') and the hook's description. app.py, keys_app.py and the keymap
loader all take the built registry — keys_app already loads config to find keymaps, so it
can list a user's own commands in the binding editor beside the built-in actions.
And the crash in §1.5 gets fixed on the way, because it is now reachable by typo and by a
command that was renamed: an unknown action name is skipped with one notification naming the
keymap and the action, not a KeyError in mount. A keymap with one bad line should cost the
user that line, not the application.
4. Sequencing
Three releases, and the editor and the hooks go first (Ted's call). Nothing in this
milestone depends on anything else in it — that is what makes the order a choice rather than a
constraint — so the order is by what users are waiting for and by how much design risk each
group carries. #1102 is open, has a
reference implementation, and needs nothing else here; the trust model under it is the most
consequential decision in M4 and benefits from being in the world early rather than shipping
last with two other features on top of it. The query history follows, because it changes a
stored format and a screen, because it is what makes an agent's queries visible to a human at
all, and because search (#429) is what
decides the format it changes to. The handoff is last: it is the largest new public surface —
two flags across two commands — and it lands better after the history, since a draft handed to
a human and the queries they run against it are then one story rather than two.
Each release maps to a run of §3: A is §3.6–§3.9, B is §3.2–§3.4, C is §3.1 and §3.5.
Numbering assumes M3's remaining work releases as 2.13.
Release A — the editor and the hooks (2.13)
PR 1 — The external editor. $EDITOR/$VISUAL only, harlequin.external.run_in_terminal(),
the suspend wrapper and its unsupported-environment path, temp-file exchange, non-zero-exit
discard, and the code_editor.edit_externally action. No config key and no trust store yet, so
it is reviewable on its own. Closes
#1102 for the request as filed.
PR 2 — The trust gate. Provenance rule, consent modal, trust store, and the configeditor key as its first consumer. The gate ships with something small enough to review it
against, and a release before anything larger runs through it.
PR 3 — [commands] in config, and actions from config. The Config member, the schema
regeneration (scripts/write_config_schema.py, and the pinned artifact), build_actions(),
the keymap-validation fix from §1.5, and --config validate's coverage of the new table.
Nothing executes yet; the keys bind to an action that reports "not implemented" in exactly one
commit, or PR 3 and PR 4 land together if that reads badly in review.
PR 4 — Running a hook. The worker, stdin/stdout, the four output modes, the empty-output
rule, timeout and cancel, error surfacing. Answers
#952.
PR 5 — Docs (harlequin-web): "External editor" and "Bring your own AI" pages, the trust
model stated plainly, and a worked claude -p config with the prompt that keeps code fences
out of the buffer.
Release B — query history, and search (2.14)
PR 6 — harlequin.query_log. The schema, the pragmas, user_version and its migration
step, the writer, retention, get_connection_hash()'s move out of catalog_cache,--no-write-history, and both commands writing. The IDE starts calling hide_secrets_in()
here — the store is the first thing it persists that could carry a value from a profile
(§1.4). Guards: hsql's import set still contains no rich, and hsql --version is still
~120ms.
PR 7 — hsql --history and --history-search. Both modes, the connection filter, the
folded sql column, --limit as "the most recent N", and the search clause. Nothing new in
the output layer — the listing goes through the emitters --catalog already uses. Adds both
modes to the skill. Closes B7's headless half and #429's.
PR 8 — The History screen reads the store. History.recent(), CatalogCache.history
removed, CACHE_VERSION to 3, and the one-time migration. This is the PR where an agent's
queries first show up in a human's History screen, and where the human's show up under--history.
PR 9 — The History screen, reconsidered. The filter input over the whole store, plus
modal, a non-focusable preview, and the screen's own bindings shown as every other screen
shows them (§1.2, §3.4). Search and the rework land together because they are the same
screen and the same reading of it: the pane is for choosing a query, not for editing one.
Closes #429 and
#850.
PR 10 — Docs (harlequin-web): the query history — where it lives, what it holds, how to
search it from either front end, and that it is a SQLite file the reader can open in Harlequin
itself — and --history in "Headless & Agents".
Release C — the handoff (2.15)
PR 11 — harlequin.invocation, and harlequin --open. The dataclass, both argv renderers,
and the IDE flag that loads files into buffers without executing them. cli.py builds anInvocation 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 overshlex.quote/list2cmdlinerather than skipped on one).query_log: row round trip; the schema migration, fromuser_version = 0forward and on a
store already at the current version; retention trimming to the newest N;redact_text()
applied tosql; an unwritable directory and an unreadable store each disabling logging
without raising; and the WAL fallback, exercised by a store wherejournal_mode=waldoes
not stick.- The concurrent write measurement becomes a real test — 4 processes × 100 inserts,
asserting every row lands and nosqlite3.Errorescapes — 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;--limittaking from
the newest end;--history-searchmatching mid-string, and its case behavior pinned where
SQLite'slikewould otherwise surprise us; the foldedsqlcolumn matching between--format tableand--format csv, asserted the way the golden-format snapshots assert it
rather than against a copy of the folding rule;--jsonemittingrowsandelapsed_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
--historyopens no connection: the mode runs against a profile whose adapter
would raise onconnect(), 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.tomlis ignored; the
same table under--config-pathis 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 aQuerySubmitted, 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 ignoresterminate(). - 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_terminalpatched, plus theSuspendNotSupportedpath
asserted as a notification — §1.6 measured that the real suspend cannot be reached inrun_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 analter 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 getsHARLEQUIN_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 ofSKILL.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 andcode_editor.formatandcode_editor.run_queryare 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 inharlequin.actions, which importsharlequin.componentsand 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
--openthat starts a query running. It would collapse the review step F1 exists for. --history --since 2h. Obvious the moment the mode exists, and one morewhereclause
once the store is a database — but--limitand newest-first cover the case this milestone
is for. Worth doing next, not now.- FTS5 over the history.
likeis 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 packagedSKILL.md, and the marketplace entry). The row ishsql --open, the query log, "Copy CLI
command", the external editor, and command hooks. - §10's
hsql open query.sqlis a subcommand, and M2 settled on mode options. The spelling
ishsql --open, reading its SQL from-c,-for 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 --jsonlis 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 20is the right feature under two wrong assumptions. The
spelling ishsql --history --json --limit 20— a mode option, and--limitrather 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 thehide_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 becomeshsql --open -f draft.sql -P prodrather than "tell them to runharlequin". - #952 is closed as
not planned, with "I won't be adding this to Harlequin. But shortly I
am releasinghsql." §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
hsqlappears 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-hostand--ssh-forwardgo tosshverbatim. Anything they cannot say goes in aHostblock, which--ssh-hostnames. - No new dependencies — the blocker on the issue since 2025 — and the user's whole
~/.ssh/configcomes for free:LocalForward,ProxyJump, agent, certificates,Match. - The tunnel starts before Textual, so a passphrase or 2FA prompt reaches a human — or
--ssh-batch-moderefuses to prompt at all, for scripts and agents. It is a child process,
notssh -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_idand 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
ATTACHofpostgres://localhost:15439/prodgoes through it like anything else. - A profile with
host = "localhost"is only correct with its tunnel up. Run it withoutssh_hostand it reaches whatever is on that port locally. Recommend a distinct local port
—15439, not5439— so that mistake is a connection refused rather than the wrong
database. Thessh_*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 3Today: ssh -fN redshift_prod, then harlequin --host localhost --port 15439 …, then
remember to kill the ssh.
[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`
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-modeHostName + 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_prodIt is what tells a user which database they are actually looking at.
5. Behavior
harlequin/ssh.py, one class:
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,
sshcannot 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 isterminate()thenkill()from acontextlib.ExitStackaroundtui.run()and thehsqlrun, plus anatexitbackstop. ExitOnForwardFailure=yesis the only-oHarlequin 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-obeats 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.hsqlis 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]:5439Probe 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 resolvedserveraliveinterval: 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:
- the tunnel is marked down when the monitor sees the child exit;
- the next database action restarts
sshand re-runs the readiness poll; - then re-runs
adapter.connect(), replacingself.connection; - 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 withSET. The notification says so rather than letting a user
believe they are still inside aBEGIN. - 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 newHarlequinSshError(HarlequinError), one new module, and — for §5.4 only — a reconnect path inapp.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 likelocalhost: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.
SshTunneltakes an argv and ports, so tests hand it a
Python child: a ~30-line loopback forwarder intests/, covering start, poll, timeout,
reuse, half-open, the death notification, and teardown. - End to end, Unix. A fake
sshonPATHthat understands-Gand-Lexercises the
real CLI path including the probe. Skipped on Windows. - Unit:
--ssh-forwardreaches argv unchanged and repeats in order;--ssh-hostis
unparsed;-Glines with a bind address, IPv6 and a socket path parse;-Greturning onlydynamicforwardis a usage error;-Ggarbage degrades; no forward anywhere is a usage
error; the argv carriesExitOnForwardFailureand, with--ssh-batch-mode,BatchMode=yesand
nothing else;-ostill means--output; a timeout with the child alive names--ssh-batch-mode; profile round trip; two ssh hosts hash differently. - Recovery:
-Greportingserveraliveinterval 0adds-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 -Vand 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, theExitStack, the notice, the cache key, the end-to-end test. (3) the keepalive default, the death
notification, --info and debug-screen reporting, regenerated schema. (4) lazy
restart-and-reconnect in the IDE (§5.4), which is the one step that touches app.py. (5) docs in harlequin-web — the
contract, and the Host block as the recommended setup — plus a CHANGELOG.md entry under[Unreleased] → Features citing #545.
8. Rejected and deferred
Rejected.
| Rewriting the adapter's host/port (the issue's sketch, and this doc's first draft) | Core would have to know which option is the host: a role= declaration no adapter has, a name-matching guess, and a DSN rewrite — three mechanisms that can be quietly wrong, to reach where a local forward reaches with none. It also bakes an ephemeral port into connection_id and breaks TLS hostname verification unasked. |
--ssh-option KEY=VALUE |
-o ProxyCommand=… is arbitrary code execution, and config files are discovered in the working directory — a cloned repo's pyproject.toml must not be able to run a command. A deny-list would mostly work, but KnownHostsCommand arrived in OpenSSH 8.5, so it is a list to revisit forever and a miss is a hole. Four flags that are a hostname, a forward spec, a boolean and a number have nothing to deny. A ProxyCommand in the user's own ~/.ssh/config is unaffected and always was. |
--ssh-config PATH (ssh -F) |
Looks safer — a path, not a keyword — but the file it names can hold a ProxyCommand. Same threat, extra step, no deny-list possible. |
--ssh-user / --ssh-port / --ssh-identity |
ssh_config spelled a second time. |
| A configurable local bind address | ssh_config and the forward spec already do it; 0.0.0.0 should take deliberate effort. |
require_tunnel = true |
A distinct local port already turns the mistake into a connection refused. |
A {tunnel_port} placeholder in the conn_str |
General, but a second thing to learn for the same result, and it makes the profile unusable without the tunnel. |
SOCKS (ssh -D) with socket.socket patched |
Keeps the real hostname, so TLS is untouched — but only works for pure-Python drivers. psycopg2, mysqlclient, ODBC and duckdb open sockets in C. |
Per-adapter --ssh-* |
N implementations, N spellings, and the adapters that need it most are out-of-tree. |
| A library adapters call | Opt-in, so "any adapter" becomes "adapters that adopted it". Fine in addition, for something like the Cloud SQL connector. |
sshtunnel |
Four years without a release, wrapping paramiko for behavior a binary on the machine already has. |
| paramiko in v1 | A cryptography dependency on every install, reimplementing the ~/.ssh/config handling users already have working. |
"Just run ssh -fN yourself" |
Works today, but means a second terminal, an orphaned tunnel to kill, and a profile that only connects if a human ran something first — which hsql in a cron job cannot do. |
Deferred, and what brings each back.
--ssh-keepalive SECONDS |
someone runs an all-day IDE session with no ~/.ssh/config and gets dropped (§3.2) |
--tunnel-command for non-SSH proxies |
a Cloud SQL or kubectl user wants Harlequin to own the proxy's lifetime, with an answer to the code-execution problem above |
a paramiko backend, harlequin[ssh] |
a user on Windows or in a container with no ssh binary |
| reconnecting a database connection that dropped with the tunnel up | a server restart or idle timeout should recover the same way §5.4 does; v1 only detects the tunnel case |
| recovering the Data Catalog's lazy loader | InteractiveCatalogItem.fetch_children() runs on the connection the adapter captured when the tree was built, which §5.4's recovery cannot reach. Expanding an unloaded node after a drop fails with a Catalog Error and stays broken until the next full refresh replaces the tree. Bring it back when catalog items can be handed a new connection, rather than by reaching into each adapter's items |
--ssh-batch-mode on by default in hsql |
interactive hsql users turn out to be rarer than scripted ones. Default-off is the same behavior in both commands today, which is easier to explain |
Each is additive: the flags are namespaced, one class has no ABC to satisfy, and no adapter is
involved in any of it.
--- METRICS ---
- Files Extracted: 14
- Estimated Token Budget: ~107438 tokens
- Canonical Reference: https://codewiki.google/github.com/tconbeer/harlequin