sql-harness 0.3.2__tar.gz → 0.3.4__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (95) hide show
  1. {sql_harness-0.3.2 → sql_harness-0.3.4}/PKG-INFO +2 -2
  2. {sql_harness-0.3.2 → sql_harness-0.3.4}/SKILL.md +33 -9
  3. {sql_harness-0.3.2 → sql_harness-0.3.4}/docs/AGENTS.md +2 -0
  4. {sql_harness-0.3.2 → sql_harness-0.3.4}/docs/README.md +1 -1
  5. {sql_harness-0.3.2 → sql_harness-0.3.4}/docs/connections-toml-example.toml +17 -2
  6. {sql_harness-0.3.2/skills/sql-harness/references → sql_harness-0.3.4/docs}/install.md +16 -3
  7. {sql_harness-0.3.2 → sql_harness-0.3.4}/docs/maintainer.md +16 -4
  8. {sql_harness-0.3.2 → sql_harness-0.3.4}/interaction-skills/ssh/auth-and-tunnels.md +23 -8
  9. {sql_harness-0.3.2 → sql_harness-0.3.4}/interaction-skills/transactions.md +1 -0
  10. {sql_harness-0.3.2 → sql_harness-0.3.4}/pyproject.toml +2 -1
  11. {sql_harness-0.3.2 → sql_harness-0.3.4}/sql-harness +1 -1
  12. {sql_harness-0.3.2 → sql_harness-0.3.4}/src/sql_harness/analytics.py +6 -2
  13. {sql_harness-0.3.2 → sql_harness-0.3.4}/src/sql_harness/cli.py +98 -41
  14. {sql_harness-0.3.2 → sql_harness-0.3.4}/src/sql_harness/config.py +19 -2
  15. {sql_harness-0.3.2 → sql_harness-0.3.4}/src/sql_harness/drivers/__init__.py +19 -2
  16. {sql_harness-0.3.2 → sql_harness-0.3.4}/src/sql_harness/drivers/mysql.py +30 -2
  17. {sql_harness-0.3.2 → sql_harness-0.3.4}/src/sql_harness/drivers/postgres.py +33 -2
  18. sql_harness-0.3.4/src/sql_harness/drivers/readonly.py +265 -0
  19. {sql_harness-0.3.2 → sql_harness-0.3.4}/src/sql_harness/drivers/redis.py +6 -1
  20. {sql_harness-0.3.2 → sql_harness-0.3.4}/src/sql_harness/drivers/sqlite.py +15 -2
  21. {sql_harness-0.3.2 → sql_harness-0.3.4}/src/sql_harness/drivers/ssh.py +73 -18
  22. {sql_harness-0.3.2 → sql_harness-0.3.4}/src/sql_harness/helpers.py +14 -0
  23. {sql_harness-0.3.2 → sql_harness-0.3.4}/src/sql_harness/manager.py +5 -1
  24. sql_harness-0.3.4/src/sql_harness/output.py +309 -0
  25. {sql_harness-0.3.2 → sql_harness-0.3.4}/src/sql_harness/run.py +14 -13
  26. {sql_harness-0.3.2 → sql_harness-0.3.4}/tests/unit/test_cli.py +237 -1
  27. {sql_harness-0.3.2 → sql_harness-0.3.4}/tests/unit/test_config.py +58 -0
  28. sql_harness-0.3.4/tests/unit/test_output.py +161 -0
  29. sql_harness-0.3.4/tests/unit/test_readonly.py +185 -0
  30. sql_harness-0.3.4/tests/unit/test_ssh.py +228 -0
  31. {sql_harness-0.3.2 → sql_harness-0.3.4}/uv.lock +1 -1
  32. sql_harness-0.3.2/.claude-plugin/marketplace.json +0 -26
  33. sql_harness-0.3.2/.claude-plugin/plugin.json +0 -13
  34. sql_harness-0.3.2/docs/install.md +0 -145
  35. sql_harness-0.3.2/skills/sql-harness/README.txt +0 -8
  36. sql_harness-0.3.2/tests/unit/test_ssh.py +0 -54
  37. {sql_harness-0.3.2 → sql_harness-0.3.4}/.env.example +0 -0
  38. {sql_harness-0.3.2 → sql_harness-0.3.4}/.github/ISSUE_TEMPLATE/bug-report.yml +0 -0
  39. {sql_harness-0.3.2 → sql_harness-0.3.4}/.github/ISSUE_TEMPLATE/config.yml +0 -0
  40. {sql_harness-0.3.2 → sql_harness-0.3.4}/.github/ISSUE_TEMPLATE/feature-request.yml +0 -0
  41. {sql_harness-0.3.2 → sql_harness-0.3.4}/.gitignore +0 -0
  42. {sql_harness-0.3.2 → sql_harness-0.3.4}/AGENTS.md +0 -0
  43. {sql_harness-0.3.2 → sql_harness-0.3.4}/agent-workspace/README.md +0 -0
  44. {sql_harness-0.3.2 → sql_harness-0.3.4}/agent-workspace/agent_helpers.example.py +0 -0
  45. {sql_harness-0.3.2 → sql_harness-0.3.4}/agent-workspace/agent_helpers.py +0 -0
  46. {sql_harness-0.3.2 → sql_harness-0.3.4}/agent-workspace/zones/example_pg/scripts/sh_demo_crud.py +0 -0
  47. {sql_harness-0.3.2 → sql_harness-0.3.4}/agent-workspace/zones/example_pg/skills/sh_demo-schema.md +0 -0
  48. {sql_harness-0.3.2 → sql_harness-0.3.4}/agent-workspace/zones/meta/skills/pool.md +0 -0
  49. {sql_harness-0.3.2 → sql_harness-0.3.4}/agent-workspace/zones/meta/skills/workspace.md +0 -0
  50. {sql_harness-0.3.2 → sql_harness-0.3.4}/docs/headless-notes.md +0 -0
  51. {sql_harness-0.3.2 → sql_harness-0.3.4}/docs/standalone-install.md +0 -0
  52. {sql_harness-0.3.2 → sql_harness-0.3.4}/interaction-skills/aggregations-advanced.md +0 -0
  53. {sql_harness-0.3.2 → sql_harness-0.3.4}/interaction-skills/aggregations.md +0 -0
  54. {sql_harness-0.3.2 → sql_harness-0.3.4}/interaction-skills/encoding-and-charset.md +0 -0
  55. {sql_harness-0.3.2 → sql_harness-0.3.4}/interaction-skills/joins-advanced.md +0 -0
  56. {sql_harness-0.3.2 → sql_harness-0.3.4}/interaction-skills/joins.md +0 -0
  57. {sql_harness-0.3.2 → sql_harness-0.3.4}/interaction-skills/large-result-sets.md +0 -0
  58. {sql_harness-0.3.2 → sql_harness-0.3.4}/interaction-skills/migrations.md +0 -0
  59. {sql_harness-0.3.2 → sql_harness-0.3.4}/interaction-skills/mysql/json-columns.md +0 -0
  60. {sql_harness-0.3.2 → sql_harness-0.3.4}/interaction-skills/pooling.md +0 -0
  61. {sql_harness-0.3.2 → sql_harness-0.3.4}/interaction-skills/postgres/btree-indexes.md +0 -0
  62. {sql_harness-0.3.2 → sql_harness-0.3.4}/interaction-skills/postgres/indexes-and-explain.md +0 -0
  63. {sql_harness-0.3.2 → sql_harness-0.3.4}/interaction-skills/postgres/json-columns.md +0 -0
  64. {sql_harness-0.3.2 → sql_harness-0.3.4}/interaction-skills/postgres/migrations.md +0 -0
  65. {sql_harness-0.3.2 → sql_harness-0.3.4}/interaction-skills/postgres/plan-reading.md +0 -0
  66. {sql_harness-0.3.2 → sql_harness-0.3.4}/interaction-skills/postgres/slow-queries-joins.md +0 -0
  67. {sql_harness-0.3.2 → sql_harness-0.3.4}/interaction-skills/postgres/specialized-indexes.md +0 -0
  68. {sql_harness-0.3.2 → sql_harness-0.3.4}/interaction-skills/postgres/table-optimization.md +0 -0
  69. {sql_harness-0.3.2 → sql_harness-0.3.4}/interaction-skills/recursive-ctes-advanced.md +0 -0
  70. {sql_harness-0.3.2 → sql_harness-0.3.4}/interaction-skills/recursive-ctes.md +0 -0
  71. {sql_harness-0.3.2 → sql_harness-0.3.4}/interaction-skills/save-run-cycle.md +0 -0
  72. {sql_harness-0.3.2 → sql_harness-0.3.4}/interaction-skills/schema-introspection.md +0 -0
  73. {sql_harness-0.3.2 → sql_harness-0.3.4}/interaction-skills/ssh/docker-via-ssh.md +0 -0
  74. {sql_harness-0.3.2 → sql_harness-0.3.4}/interaction-skills/ssh/ssh.md +0 -0
  75. {sql_harness-0.3.2 → sql_harness-0.3.4}/interaction-skills/timeouts-and-cancellation.md +0 -0
  76. {sql_harness-0.3.2 → sql_harness-0.3.4}/interaction-skills/window-functions-advanced.md +0 -0
  77. {sql_harness-0.3.2 → sql_harness-0.3.4}/interaction-skills/window-functions.md +0 -0
  78. {sql_harness-0.3.2 → sql_harness-0.3.4}/interaction-skills/zone-skill-auto-surface.md +0 -0
  79. {sql_harness-0.3.2 → sql_harness-0.3.4}/src/sql_harness/__init__.py +0 -0
  80. {sql_harness-0.3.2 → sql_harness-0.3.4}/src/sql_harness/agent_loader.py +0 -0
  81. {sql_harness-0.3.2 → sql_harness-0.3.4}/src/sql_harness/paths.py +0 -0
  82. {sql_harness-0.3.2 → sql_harness-0.3.4}/tests/__init__.py +0 -0
  83. {sql_harness-0.3.2 → sql_harness-0.3.4}/tests/conftest.py +0 -0
  84. {sql_harness-0.3.2 → sql_harness-0.3.4}/tests/integration/__init__.py +0 -0
  85. {sql_harness-0.3.2 → sql_harness-0.3.4}/tests/integration/test_mysql.py +0 -0
  86. {sql_harness-0.3.2 → sql_harness-0.3.4}/tests/integration/test_pg_optimization.py +0 -0
  87. {sql_harness-0.3.2 → sql_harness-0.3.4}/tests/integration/test_pgexercises_practice.py +0 -0
  88. {sql_harness-0.3.2 → sql_harness-0.3.4}/tests/integration/test_postgres.py +0 -0
  89. {sql_harness-0.3.2 → sql_harness-0.3.4}/tests/integration/test_ssh.py +0 -0
  90. {sql_harness-0.3.2 → sql_harness-0.3.4}/tests/unit/__init__.py +0 -0
  91. {sql_harness-0.3.2 → sql_harness-0.3.4}/tests/unit/test_agent_loader.py +0 -0
  92. {sql_harness-0.3.2 → sql_harness-0.3.4}/tests/unit/test_analytics.py +0 -0
  93. {sql_harness-0.3.2 → sql_harness-0.3.4}/tests/unit/test_helpers.py +0 -0
  94. {sql_harness-0.3.2 → sql_harness-0.3.4}/tests/unit/test_manager.py +0 -0
  95. {sql_harness-0.3.2 → sql_harness-0.3.4}/tests/unit/test_paths.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: sql-harness
3
- Version: 0.3.2
3
+ Version: 0.3.4
4
4
  Summary: Single-process SQL + SSH CLI for LLM agents. PostgreSQL/MySQL/SSH via SQLAlchemy + paramiko. Plaintext credentials in one TOML file. Helpers auto-injected into the heredoc namespace.
5
5
  Project-URL: Source, https://github.com/zhaoliuxue/much_bigpy/tree/master/lab/sql_harness
6
6
  Project-URL: Issues, https://github.com/zhaoliuxue/much_bigpy/issues
@@ -37,7 +37,7 @@ uv sync
37
37
  uv run sql-harness init
38
38
 
39
39
  # 3. Add a connection
40
- uv run sql-harness add local_pg --driver postgres --url 'postgresql://postgres:postgres@localhost:5432/postgres'
40
+ uv run sql-harness add --name local_pg --driver postgres --url 'postgresql://postgres:postgres@localhost:5432/postgres'
41
41
 
42
42
  # 4. Test it
43
43
  uv run sql-harness test local_pg
@@ -7,7 +7,7 @@ description: "Always use sql-harness for SQL, SSH, and database operations — q
7
7
 
8
8
  A single-process SQL + SSH CLI for agents. 5 drivers (`postgres`, `mysql`, `sqlite`, `redis`, `ssh`) share one heredoc namespace. Connections live in plaintext in one TOML file.
9
9
 
10
- For setup, read `install.md`. For stuck-point mechanics, see `interaction-skills/` (cross-DB) and `interaction-skills/postgres/` (PG depth).
10
+ For setup, read `install.md` (shipped next to this file; in a source checkout it's `docs/install.md`). For stuck-point mechanics, see `interaction-skills/` (cross-DB) and `interaction-skills/postgres/` (PG depth).
11
11
 
12
12
  ## Reach for sql-harness when...
13
13
 
@@ -26,9 +26,9 @@ For setup, read `install.md`. For stuck-point mechanics, see `interaction-skills
26
26
  ```bash
27
27
  sql-harness --help
28
28
  sql-harness list
29
- sql-harness add <name> --driver postgres --url 'postgresql://user:pw@host/db'
30
- sql-harness add <name> --driver mysql --url 'mysql+pymysql://user:pw@host/db'
31
- sql-harness add <name> --driver ssh --url 'ssh://user@host:22?key=/path/id_ed25519'
29
+ sql-harness add --name <name> --driver postgres --url 'postgresql://user:pw@host/db'
30
+ sql-harness add --name <name> --driver mysql --url 'mysql+pymysql://user:pw@host/db'
31
+ sql-harness add --name <name> --driver ssh --url 'ssh://user@host:22?key=/path/id_ed25519'
32
32
  sql-harness test <name>
33
33
 
34
34
  sql-harness <<'PY'
@@ -48,7 +48,7 @@ PY
48
48
 
49
49
  ## Drivers & capabilities
50
50
 
51
- Five drivers, one heredoc namespace. URL scheme must match the backend: MySQL requires `mysql+pymysql://`; PostgreSQL accepts `postgres://` / `postgresql://` (normalized to `postgresql+psycopg://`); SSH accepts `ssh://`, `ssh+password://`, `ssh+key://`.
51
+ Five drivers, one heredoc namespace. URL scheme must match the backend: MySQL requires `mysql+pymysql://`; PostgreSQL accepts `postgres://` / `postgresql://` (normalized to `postgresql+psycopg://`); SSH accepts `ssh://`, `ssh+password://`, `ssh+key://`. SSH password auth may come from the connection's standalone `password` key instead of the URL (precedence: URL > `password` > `$BH_SSH_PASSWORD`); a connection that sets a password uses password auth exclusively, ignoring local `~/.ssh` keys.
52
52
 
53
53
  ### Postgres (`postgres://`)
54
54
 
@@ -71,7 +71,7 @@ Standard SQLAlchemy + PyMySQL. JSON syntax differs from PG — see `interaction-
71
71
 
72
72
  ### SQLite (`sqlite:///path.db`)
73
73
 
74
- Local dev / tests. All standard helpers work. Use `sql-harness add --driver sqlite --url 'sqlite:///<absolute-path>'` (SQLite needs absolute path).
74
+ Local dev / tests. All standard helpers work. Use `sql-harness add --name <name> --driver sqlite --url 'sqlite:///<absolute-path>'` (SQLite needs absolute path).
75
75
 
76
76
  ### Redis (`redis://host:port`)
77
77
 
@@ -103,7 +103,7 @@ See `interaction-skills/ssh/` for: `ssh.md` (helpers + CLI + gotchas), `docker-v
103
103
  | `list_tables(schema=None)` | `list[str]` | Schema recon — always start here |
104
104
  | `describe(table, schema=None)` | `list[dict]` | Columns + types — before writing SQL |
105
105
  | `table(name, schema=None, limit=None)` | `list[dict]` | Quick peek: `SELECT * FROM x LIMIT n` |
106
- | `with_transaction()` | context manager yielding `Connection` | ≥ 2 statements / DDL / atomicity |
106
+ | `with_transaction()` | context manager yielding `Connection` | ≥ 2 DML statements / atomicity (DDL: PG+SQLite only) |
107
107
  | `run_sql_file(path)` | `list[dict]` | Multi-statement `.sql`, one transaction |
108
108
  | `server_version()` | `str` | Round-trip liveness check |
109
109
  | `connection_info()` | `dict` | Current workspace metadata |
@@ -159,7 +159,8 @@ Reached via `apply_skill("pool")` etc. from any active zone — `zones/meta/skil
159
159
 
160
160
  - **Start with `list_tables` + `describe`, not `query`.** Before writing SQL on an unknown DB, run `list_tables(schema=None)` then `describe("table", schema=None)` to learn the columns. Skips the trial-and-error SELECT cycle.
161
161
  - **Set `BH_SQL_ZONE_SKILLS=1` once** so every `use_workspace()` surfaces that zone's per-DSN skill docs as a hint list (10 filenames max). Then `apply_skill(name)` reads the body when relevant.
162
- - **Default to `with_transaction()`** for any write with ≥ 2 statements (insert + update, drop + create, …). Atomic + automatic rollback on exception.
162
+ - **Default to `with_transaction()`** for any write with ≥ 2 statements (insert + update, update + delete, …). Automatic rollback on exception — **for DML**. DDL is a different story: PostgreSQL and SQLite roll back `CREATE`/`DROP`/`ALTER` inside a transaction, **MySQL does not** (it implicitly commits), so a `drop + create` migration that fails halfway leaves you with neither table. Run DDL migrations with a backup or a reversible plan, not on the strength of the transaction. See `interaction-skills/migrations.md`.
163
+ - **Transactions are only real on a transactional engine.** A MySQL table created on a server whose `@@default_storage_engine` is not InnoDB (e.g. MyISAM) ignores `ROLLBACK` *silently* — the write sticks and nothing warns you. Check with `SELECT @@default_storage_engine`, and write `ENGINE=InnoDB` explicitly if you need rollback.
163
164
  - **For PG performance tasks, reach for the 8 PG-only helpers first** (`explain_analyze`, `slow_queries`, `unused_indexes`, `seq_scan_heavy`, `table_stats`, `index_usage_stats`, `missing_indexes_hint`). Don't hand-parse `pg_stat_*` SQL.
164
165
  - **For remote tasks, reach for SSH workspace first.** Don't reach for raw `subprocess` + ssh-key files in agent code — `ssh_exec("systemctl restart myapp")` is one line.
165
166
  - **For "insert returning the new id"**: psycopg supports `INSERT … RETURNING id`; MySQL has no `RETURNING` — use `execute(...)` and read `result.lastrowid`.
@@ -184,11 +185,34 @@ Reached via `apply_skill("pool")` etc. from any active zone — `zones/meta/skil
184
185
  ## Gotchas
185
186
 
186
187
  - Driver label in TOML must match the URL scheme (`postgres` ⇄ `postgresql://`, `mysql` ⇄ `mysql+pymysql://`, `ssh` ⇄ `ssh://` / `ssh+password://` / `ssh+key://`).
187
- - Passwords in TOML are plaintext; use `${env:VAR}` indirection for prod secrets.
188
+ - Passwords in TOML are plaintext; use `${env:VAR}` indirection for prod secrets. SSH accepts a standalone `password` key (keeps the URL clean); when one is set, local `~/.ssh` keys are ignored.
188
189
  - `query()` is for SELECTs only. For INSERTs, use `execute()`.
189
190
  - For tables > 10k rows, use `engine.connect().execution_options(stream_results=True)` and iterate manually — `query()` loads everything into memory.
190
191
  - SSH SFTP subsystem may be disabled on the remote host. `ssh_upload/ssh_download` raise `RuntimeError("SFTP subsystem not available")`; fall back to `tar -czf - | ssh host tar -xzf -` via `ssh_exec`.
191
192
  - Default `ssh_exec` timeout is 30 s. Long-running commands (e.g. `pg_dump`, `tar czf`) need explicit `timeout=`.
193
+ - **Long output is spilled to a file, not printed.** A failed run whose traceback is long, and any `ssh exec` / `ssh run-script` output past a few KB, are written to `$BH_SQL_HOME/tmp/output/` and replaced by a pointer line naming the path, line count and size. That is deliberate — read the file when you actually need it. `BH_SQL_TRACEBACK=1` forces the raw traceback to stderr instead. The full traceback always also lands in `$BH_SQL_HOME/sql-harness.log` (NDJSON), whatever the terminal shows.
194
+
195
+ ## Read-only connections (`read_only = true`)
196
+
197
+ For a sensitive database, mark the connection read-only. Two rails are installed at the driver layer, both attached to the **engine** rather than to `query()`/`execute()` — so `conn.execute()`, `run_sql_file()` and any path that skips the helpers are covered too.
198
+
199
+ ```toml
200
+ [[connections]]
201
+ name = "prod_ro"
202
+ driver = "mysql"
203
+ url = "mysql+pymysql://ro_user@host:3306/proddb"
204
+ read_only = true
205
+ ```
206
+
207
+ ```bash
208
+ sql-harness add --name prod_ro --driver mysql --url '...' --read-only
209
+ ```
210
+
211
+ What it refuses: anything whose verb is not `SELECT`/`SHOW`/`DESCRIBE`/`EXPLAIN`/`TABLE`/`VALUES`/`PRAGMA`, plus transaction control. **Unrecognised statements are refused, not permitted** — that is why it is an allow-list: a deny-list misses `CALL`, `DO`, `LOAD DATA`, `HANDLER` and every future statement type. It also handles the dodges a naive keyword match falls for: `/*!50000 DROP */` (MySQL executes those), `SELECT 1; DROP TABLE t`, `WITH x AS (...) DELETE`, `EXPLAIN ANALYZE DELETE` (PostgreSQL executes it), and `#` — which is a comment in MySQL but the XOR operator in PostgreSQL, so treating it as a comment there would hide a write. On MySQL it additionally refuses `INTO OUTFILE`/`INTO DUMPFILE`/`FOR UPDATE`/`LOCK IN SHARE MODE`; on PostgreSQL, `SELECT ... INTO` (creates a table) and the `FOR ...` lock variants.
212
+
213
+ Where the backend has a session setting, it is applied on **every new pooled connection**: `SET SESSION TRANSACTION READ ONLY` (MySQL) / `SET SESSION CHARACTERISTICS AS TRANSACTION READ ONLY` (PostgreSQL). That rail is enforced by the server, so it catches anything that slips past the statement guard (e.g. a stored procedure).
214
+
215
+ **This is a guard rail, not a security boundary.** Both rails are escapable from the same session — a caller can run `SET SESSION TRANSACTION READ WRITE` and carry on, and any client-side check is pattern matching rather than a parser. The only hard boundary is the database account: use `GRANT SELECT ON db.* TO ...` for a database you genuinely must not write to, and treat everything above as defence in depth on top of it. SQLite has no session rail at all; for a file database the real boundary is the URL (`sqlite:///file:path?mode=ro&uri=true`). SSH workspaces ignore the flag — a remote shell is not a statement stream.
192
216
 
193
217
  ## Domain / table skills
194
218
 
@@ -12,10 +12,12 @@ Core code lives in `lab/sql_harness/src/sql_harness/`:
12
12
  - `config.py` — TOML schema + ConnectionConfig / ConnectionsConfig dataclasses
13
13
  - `paths.py` — XDG-style state directory resolution
14
14
  - `drivers/` — backend abstractions (postgres, mysql, redis stub, sqlite for tests)
15
+ - `drivers/readonly.py` — read-only statement guard shared by the SQL drivers: comment/literal-stripping, allow-list verb check, and `install_guard()` which attaches both rails to an engine. Read its module docstring before relying on it — it is a guard rail, not a security boundary
15
16
  - `manager.py` — SqlHarness class: holds engines, workspaces, skills registry
16
17
  - `helpers.py` — heredoc helpers (auto-imported)
17
18
  - `agent_loader.py` — loads `$BH_SQL_AGENT_WORKSPACE/agent_helpers.py`
18
19
  - `analytics.py` — event bus (observer) + JSON stats + NDJSON execution log: CLI call counts, doc-read tracking, command/error history; `BH_SQL_ANALYTICS` (default ON), view with `sql-harness stats`
20
+ - `output.py` — terminal output policy: UTF-8 stream setup, long-output spill to `$BH_SQL_HOME/tmp/output/`, concise error rendering; `BH_SQL_TRACEBACK=1` forces raw tracebacks
19
21
  - `run.py` — heredoc entry; delegates to CLI otherwise
20
22
  - `cli.py` — argparse subparser CLI (18 subcommands)
21
23
 
@@ -14,7 +14,7 @@ uv sync
14
14
  uv run sql-harness init
15
15
 
16
16
  # 3. Add a connection
17
- uv run sql-harness add local_pg --driver postgres --url 'postgresql://postgres:postgres@localhost:5432/postgres'
17
+ uv run sql-harness add --name local_pg --driver postgres --url 'postgresql://postgres:postgres@localhost:5432/postgres'
18
18
 
19
19
  # 4. Test it
20
20
  uv run sql-harness test local_pg
@@ -18,7 +18,9 @@ echo = false
18
18
  # Connections
19
19
  # ---------------------------------------------------------------------------
20
20
  # Each [[connections]] block defines a named profile. Drivers: postgres, mysql,
21
- # redis (stub), sqlite. URL must match the driver.
21
+ # redis (stub), sqlite, ssh / ssh+password / ssh+key. URL must match the driver.
22
+ # Optional keys: description, password, pool_size, pool_recycle, pre_ping, echo,
23
+ # application_name. `${env:VAR}` is expanded in both `url` and `password`.
22
24
 
23
25
  [[connections]]
24
26
  name = "local_pg"
@@ -51,11 +53,24 @@ driver = "ssh"
51
53
  url = "ssh://app@10.0.0.5:2222"
52
54
  description = "Production application host (key auth via $BH_SSH_KEY or ~/.ssh/id_ed25519)"
53
55
 
56
+ # Password auth, credentials kept out of the URL (recommended). Each connection
57
+ # carries its own password, so switching workspaces needs no env fiddling.
58
+ # Precedence: URL-embedded password > this field > $BH_SSH_PASSWORD.
59
+ # Note: a connection with a password uses password auth exclusively — local
60
+ # ~/.ssh keys are not consulted.
61
+ [[connections]]
62
+ name = "iot_server"
63
+ driver = "ssh"
64
+ url = "ssh://113.44.193.72:22"
65
+ password = "${env:IOT_SSH_PASSWORD}"
66
+ description = "IoT host (password auth)"
67
+
68
+ # URL-embedded password also works (driver label may be ssh or ssh+password).
54
69
  [[connections]]
55
70
  name = "lab-server"
56
71
  driver = "ssh+password"
57
72
  url = "ssh+password://lab:lab@192.168.1.10:22"
58
- description = "Lab box (password auth)"
73
+ description = "Lab box (password auth, password in URL)"
59
74
 
60
75
  # ---------------------------------------------------------------------------
61
76
  # Optional per-connection pool overrides (alternative syntax)
@@ -27,7 +27,8 @@ Writes `~/.config/sql-harness/connections.toml` (or `$BH_SQL_HOME/connections.to
27
27
  ## 3. Add your first connection
28
28
 
29
29
  ```bash
30
- uv run sql-harness add my_pg \
30
+ uv run sql-harness add \
31
+ --name my_pg \
31
32
  --driver postgres \
32
33
  --url 'postgresql://user:pw@localhost:5432/db' \
33
34
  --description "My dev DB"
@@ -79,9 +80,21 @@ name = "prod_pg"
79
80
  driver = "postgres"
80
81
  url = "${env:PROD_PG_URL}" # env-var indirection
81
82
  pool_size = 10
83
+
84
+ [[connections]]
85
+ name = "iot_server"
86
+ driver = "ssh"
87
+ url = "ssh://113.44.193.72:22"
88
+ password = "${env:IOT_SSH_PASSWORD}" # standalone secret — keeps the URL clean
82
89
  ```
83
90
 
84
- `${env:VAR}` placeholders inside `url` are expanded at load time from the process environment — keep prod secrets out of the file.
91
+ `${env:VAR}` placeholders inside `url` and `password` are expanded at load time from the process environment — keep prod secrets out of the file.
92
+
93
+ Optional per-connection keys: `description`, `password`, `read_only`, `pool_size`, `pool_recycle`, `pre_ping`, `echo`, `application_name`.
94
+
95
+ `read_only = true` refuses writes on that connection — a statement allow-list on every statement the engine sends, plus the server-side session read-only setting where the backend has one. Use it for sensitive databases, but note it is a **guard rail, not a security boundary**: the session setting is escapable from the same session, and any client-side check is pattern matching. Pair it with a `SELECT`-only database account, which is the actual boundary. See `SKILL.md` → "Read-only connections".
96
+
97
+ SSH password precedence (highest first): password embedded in `url` > the `password` key > `$BH_SSH_PASSWORD`. A connection that sets a password uses password auth exclusively — local `~/.ssh` keys are not consulted (see `interaction-skills/ssh/auth-and-tunnels.md`).
85
98
 
86
99
  ## Configuration env vars
87
100
 
@@ -110,7 +123,7 @@ pool_size = 10
110
123
  | `ssh` / `ssh+password` / `ssh+key` | remote shell + SFTP (paramiko) | `paramiko>=3.4` | ✅ |
111
124
 
112
125
  To enable Redis: `uv add "redis>=5.0,<6"` then implement `drivers/redis.py`.
113
- To add another SSH host: append a `[[connections]]` block with `driver = "ssh"` and an `ssh://...` URL.
126
+ To add another SSH host: append a `[[connections]]` block with `driver = "ssh"` and an `ssh://...` URL. Use the `password` key for password auth, or `?key=/abs/path` for a key outside the default locations. `sql-harness test <name>` probes SSH with a remote `echo` instead of `SELECT 1`.
114
127
 
115
128
  ## Troubleshooting
116
129
 
@@ -17,14 +17,26 @@ hand-synced copies. `pyproject.toml` `[tool.hatch.build.targets.wheel.force-incl
17
17
  maps the repo docs into the wheel:
18
18
 
19
19
  - `SKILL.md` → `sql_harness/_skills/SKILL.md`
20
+ - `docs/install.md` → `sql_harness/_skills/install.md`
20
21
  - `interaction-skills` → `sql_harness/_skills/interaction-skills`
21
22
  - `agent-workspace/zones/meta/skills` → `sql_harness/_skills/zones-meta-skills`
22
23
 
23
24
  The root `SKILL.md` is the **single source of truth** (v0.3.0 consolidation —
24
25
  the old `skills/sql-harness/SKILL.md` and `src/sql_harness/SKILL.md` copies
25
- were removed). After editing it, just `uv build` again. `sql-harness skill`
26
- reads `sql_harness/_skills/SKILL.md` from the installed package and falls
27
- back to the repo-root `SKILL.md` in a source checkout.
26
+ were removed; v0.3.4 removed the last hand-synced copy,
27
+ `skills/sql-harness/references/install.md`). After editing it, just
28
+ `uv build` again. `sql-harness skill` reads `sql_harness/_skills/SKILL.md`
29
+ from the installed package and falls back to the repo-root `SKILL.md` in a
30
+ source checkout.
31
+
32
+ `SKILL.md` references `install.md` as a sibling file — that only resolves
33
+ because `docs/install.md` is force-included into the bundle as
34
+ `_skills/install.md`. Dropping it from the force-include list breaks the
35
+ reference for every `skill install` user.
36
+
37
+ `skill install` needs a real (non-editable) wheel install to work: it reads
38
+ `sql_harness/_skills/`, which only exists after `uv build`. In a bare source
39
+ checkout `_bundled_skills_root()` returns `None` and the command exits 2.
28
40
 
29
41
  ## Build outputs
30
42
 
@@ -44,7 +56,7 @@ The wheel contains the package modules + the `_skills/` doc bundle
44
56
  ```bash
45
57
  # standalone venv
46
58
  uv sync
47
- uv run pytest tests/unit -q # 80 tests must pass
59
+ uv run pytest tests/unit -q # 133 tests must pass
48
60
  uv run sql-harness --help # CLI works
49
61
  uv run sql-harness skill # SKILL.md emit works
50
62
  ```
@@ -16,15 +16,29 @@
16
16
 
17
17
  ### 1.1 三种 scheme 对照
18
18
 
19
- `drivers/ssh.py:_parse_url` 支持的三种 URL 形式:
19
+ `drivers/ssh.py` 认这三种 URL 形式:
20
20
 
21
- | URL 形式 | 何时用 | key 顺序 |
21
+ | URL 形式 | 何时用 | 认证方式 |
22
22
  |---|---|---|
23
- | `ssh://user@host:port` | 默认 key auth | `?key=...``$BH_SSH_KEY` → `~/.ssh/id_ed25519` `id_rsa` → `id_ecdsa`(`KEY_FALLBACK`) |
24
- | `ssh+key://user@host:port?key=/abs/path` | key 不在默认路径 | URL 里的 `?key=` 单独覆盖 |
25
- | `ssh+password://user:pw@host:port` | 跳板 / 临时主机,没配 key | URL 嵌入密码;也可用 `$BH_SSH_PASSWORD` |
23
+ | `ssh://user@host:port` | 默认 | 显式 key(`?key=`/`$BH_SSH_KEY`)→ key;否则配了密码纯密码;都没有自动找 `~/.ssh` key |
24
+ | `ssh+key://user@host:port?key=/abs/path` | key 不在默认路径 | key 认证,密码作后备 |
25
+ | `ssh+password://user[:pw]@host:port` | 跳板 / 临时主机 | 纯密码认证;密码可嵌 URL,也可放字段/env |
26
26
 
27
- **注意**:`ssh+password` 要求密码嵌入 URL。不想明文落 TOML,把密码放 `$BH_SSH_PASSWORD` 并用 `ssh://user@host`,但驱动**不会**自动回退到 env 密码(仅 key 走 env fallback)。这是常见混淆点。
27
+ **密码来源优先级**(高 低):URL 内嵌 > 连接里的 `password` 字段 > `$BH_SSH_PASSWORD`。
28
+
29
+ **`password` 字段**(推荐——密码按连接存,切 workspace 不用动环境变量):
30
+
31
+ ```toml
32
+ [[connections]]
33
+ name = "iot_server"
34
+ driver = "ssh"
35
+ url = "ssh://113.44.193.72:22"
36
+ password = "${env:IOT_SSH_PASSWORD}" # 也可直接写明文
37
+ ```
38
+
39
+ **认证策略**(`_open_client`):显式给 key(`?key=`/`$BH_SSH_KEY`)→ key 优先、密码兜底;没给 key 但配了密码 → **纯密码**,不翻 `~/.ssh`;都没有 → 自动发现 `~/.ssh/id_{ed25519,rsa,ecdsa}`。
40
+
41
+ > 第三条很关键:paramiko 开着 `look_for_keys` 会逐个试 `~/.ssh` 里的 key,只要里面有一把解析不了的(旧 DSA key 是常见雷),认证线程直接抛异常、**在密码之前**就中断握手。所以配了密码就不会被本地散装 key 劫持。
28
42
 
29
43
  ### 1.2 用哪个连接串
30
44
 
@@ -32,7 +46,7 @@
32
46
  有 ed25519 key 在 ~/.ssh/ 且 authorized_keys 已加?
33
47
  ├─ 是 → ssh://user@host (默认 key path)
34
48
  └─ 否:
35
- 有密码但没 key? → ssh+password://user:pw@host
49
+ 有密码?连接里加 password = "..."(driver 用 ssh 即可)
36
50
  key 在非默认路径? → ssh://user@host?key=/path/to/k
37
51
  key 在 ssh-agent? → §1.3
38
52
  ```
@@ -131,7 +145,8 @@ sql-harness 不内置 SSH SOCKS 代理;本机 `psql`/`TablePlus` 直连躲在
131
145
  ## Gotchas
132
146
 
133
147
  - **paramiko 不会自动用 ssh-agent**——见 §1.3。
134
- - **`ssh+password://` 必须嵌入密码**——`BH_SSH_PASSWORD` env 不会回退(仅 key env fallback)。
148
+ - **密码优先级**:URL 内嵌 > `password` 字段 > `$BH_SSH_PASSWORD`。配了密码即纯密码认证,本地 key 不再参与(见 §1.1)。
149
+ - **服务端禁密码登录**:报 `Authentication failed` 时先确认服务端开了 `PasswordAuthentication`,别急着改本地配置。
135
150
  - **堡垒机转发时 `ssh -fN` 在某些堡垒机受限**——`AllowTcpForwarding no`。失败就 §2.2 走 paramiko `request_port_forward`。
136
151
  - **`request_port_forward` 返回的对象必须在 finally 里 cancel**,否则下次 `use_workspace` 复用 transport 报端口占用。
137
152
  - **workspace disconnect 不自动清 tunnel**——硬规矩:tunnel 用 try/finally 包起来。
@@ -55,3 +55,4 @@ for attempt in range(3):
55
55
  - `text("SELECT ...").execution_options(...)` is per-statement; transaction-level options belong on the `SET` line.
56
56
  - Nested `with_transaction()` will create a SAVEPOINT (PostgreSQL) or fail (MySQL). Don't nest.
57
57
  - A failed statement inside a transaction does NOT auto-rollback the whole transaction in some dialects — call `conn.rollback()` explicitly if you've swallowed the exception.
58
+ - **A non-transactional table ignores `ROLLBACK` silently.** On MySQL, check `SELECT @@default_storage_engine` before trusting a rollback: if it is not InnoDB, any table created without an explicit `ENGINE=InnoDB` accepts `BEGIN`/`ROLLBACK` and discards them — the row is still there afterwards and nothing errors. Reach for `with_transaction()` on such tables and you get the *appearance* of atomicity with none of the substance.
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "sql-harness"
3
- version = "0.3.2"
3
+ version = "0.3.4"
4
4
  description = "Single-process SQL + SSH CLI for LLM agents. PostgreSQL/MySQL/SSH via SQLAlchemy + paramiko. Plaintext credentials in one TOML file. Helpers auto-injected into the heredoc namespace."
5
5
  readme = "docs/README.md"
6
6
  license = { text = "MIT" }
@@ -50,6 +50,7 @@ packages = ["src/sql_harness"]
50
50
  # shipping its SKILL.md in the wheel, but extended to the whole doc bundle.
51
51
  # Paths are relative to this pyproject.toml (lab/sql_harness/).
52
52
  "SKILL.md" = "sql_harness/_skills/SKILL.md"
53
+ "docs/install.md" = "sql_harness/_skills/install.md"
53
54
  "interaction-skills" = "sql_harness/_skills/interaction-skills"
54
55
  "agent-workspace/zones/meta/skills" = "sql_harness/_skills/zones-meta-skills"
55
56
 
@@ -16,7 +16,7 @@ set -euo pipefail
16
16
  # browser-harness keeps the launcher at repo root; sql-harness's "repo root" is lab/sql_harness/).
17
17
  SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
18
18
  ROOT="$SCRIPT_DIR"
19
- SRC="$ROOT/src"r
19
+ SRC="$ROOT/src"
20
20
 
21
21
  # Isolate dev state.
22
22
  export BH_SQL_HOME="$ROOT/.sql-harness-dev"
@@ -48,8 +48,12 @@ CLI_INVOKED = "cli.invoked" # payload: {"subcommand": str}
48
48
  DOC_READ = "doc.read" # payload: {"doc": str, "via": str}
49
49
  COMMAND_EXECUTED = "command.executed"
50
50
  # payload: {"argv": list[str], "code": str|None, "ok": bool,
51
- # "exit_code": int, "error": str|None} (argv = CLI args incl. subcommand;
52
- # code = heredoc body when run via stdin; error = message/traceback)
51
+ # "exit_code": int, "error": str|None}
52
+ # argv = CLI args including the subcommand; [] in heredoc mode
53
+ # code = the heredoc body; None in CLI mode
54
+ # error = the FULL traceback when the run died on an uncaught exception,
55
+ # else None. stderr shows a compressed form (see output.py), so this
56
+ # log is the only complete channel — it keeps everything.
53
57
 
54
58
  _OFF_VALUES = {"0", "off", "false", "no"}
55
59
  DOC_READS_CAP = 500