sql-harness 0.3.3__tar.gz → 0.3.5__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.
- {sql_harness-0.3.3 → sql_harness-0.3.5}/PKG-INFO +6 -2
- {sql_harness-0.3.3 → sql_harness-0.3.5}/SKILL.md +32 -8
- {sql_harness-0.3.3 → sql_harness-0.3.5}/docs/AGENTS.md +4 -1
- {sql_harness-0.3.3 → sql_harness-0.3.5}/docs/README.md +4 -1
- {sql_harness-0.3.3/skills/sql-harness/references → sql_harness-0.3.5/docs}/install.md +5 -2
- {sql_harness-0.3.3 → sql_harness-0.3.5}/docs/maintainer.md +16 -4
- {sql_harness-0.3.3 → sql_harness-0.3.5}/interaction-skills/transactions.md +1 -0
- {sql_harness-0.3.3 → sql_harness-0.3.5}/pyproject.toml +7 -1
- sql_harness-0.3.5/src/sql_harness/_web/app.css +191 -0
- sql_harness-0.3.5/src/sql_harness/_web/app.js +318 -0
- sql_harness-0.3.5/src/sql_harness/_web/index.html +42 -0
- {sql_harness-0.3.3 → sql_harness-0.3.5}/src/sql_harness/analytics.py +6 -2
- {sql_harness-0.3.3 → sql_harness-0.3.5}/src/sql_harness/cli.py +128 -26
- {sql_harness-0.3.3 → sql_harness-0.3.5}/src/sql_harness/config.py +4 -0
- {sql_harness-0.3.3 → sql_harness-0.3.5}/src/sql_harness/drivers/__init__.py +13 -1
- {sql_harness-0.3.3 → sql_harness-0.3.5}/src/sql_harness/drivers/mysql.py +29 -2
- {sql_harness-0.3.3 → sql_harness-0.3.5}/src/sql_harness/drivers/postgres.py +32 -2
- sql_harness-0.3.5/src/sql_harness/drivers/readonly.py +265 -0
- {sql_harness-0.3.3 → sql_harness-0.3.5}/src/sql_harness/drivers/redis.py +6 -1
- {sql_harness-0.3.3 → sql_harness-0.3.5}/src/sql_harness/drivers/sqlite.py +14 -2
- {sql_harness-0.3.3 → sql_harness-0.3.5}/src/sql_harness/drivers/ssh.py +6 -2
- {sql_harness-0.3.3 → sql_harness-0.3.5}/src/sql_harness/helpers.py +14 -0
- {sql_harness-0.3.3 → sql_harness-0.3.5}/src/sql_harness/manager.py +3 -1
- sql_harness-0.3.5/src/sql_harness/output.py +309 -0
- {sql_harness-0.3.3 → sql_harness-0.3.5}/src/sql_harness/run.py +14 -13
- sql_harness-0.3.5/src/sql_harness/webapp.py +723 -0
- {sql_harness-0.3.3 → sql_harness-0.3.5}/tests/unit/test_cli.py +128 -2
- sql_harness-0.3.5/tests/unit/test_output.py +161 -0
- sql_harness-0.3.5/tests/unit/test_readonly.py +185 -0
- sql_harness-0.3.5/tests/unit/test_webapp.py +547 -0
- sql_harness-0.3.5/uv.lock +554 -0
- sql_harness-0.3.3/docs/install.md +0 -155
- sql_harness-0.3.3/skills/sql-harness/README.txt +0 -8
- sql_harness-0.3.3/uv.lock +0 -531
- {sql_harness-0.3.3 → sql_harness-0.3.5}/.env.example +0 -0
- {sql_harness-0.3.3 → sql_harness-0.3.5}/.github/ISSUE_TEMPLATE/bug-report.yml +0 -0
- {sql_harness-0.3.3 → sql_harness-0.3.5}/.github/ISSUE_TEMPLATE/config.yml +0 -0
- {sql_harness-0.3.3 → sql_harness-0.3.5}/.github/ISSUE_TEMPLATE/feature-request.yml +0 -0
- {sql_harness-0.3.3 → sql_harness-0.3.5}/.gitignore +0 -0
- {sql_harness-0.3.3 → sql_harness-0.3.5}/AGENTS.md +0 -0
- {sql_harness-0.3.3 → sql_harness-0.3.5}/agent-workspace/README.md +0 -0
- {sql_harness-0.3.3 → sql_harness-0.3.5}/agent-workspace/agent_helpers.example.py +0 -0
- {sql_harness-0.3.3 → sql_harness-0.3.5}/agent-workspace/agent_helpers.py +0 -0
- {sql_harness-0.3.3 → sql_harness-0.3.5}/agent-workspace/zones/example_pg/scripts/sh_demo_crud.py +0 -0
- {sql_harness-0.3.3 → sql_harness-0.3.5}/agent-workspace/zones/example_pg/skills/sh_demo-schema.md +0 -0
- {sql_harness-0.3.3 → sql_harness-0.3.5}/agent-workspace/zones/meta/skills/pool.md +0 -0
- {sql_harness-0.3.3 → sql_harness-0.3.5}/agent-workspace/zones/meta/skills/workspace.md +0 -0
- {sql_harness-0.3.3 → sql_harness-0.3.5}/docs/connections-toml-example.toml +0 -0
- {sql_harness-0.3.3 → sql_harness-0.3.5}/docs/headless-notes.md +0 -0
- {sql_harness-0.3.3 → sql_harness-0.3.5}/docs/standalone-install.md +0 -0
- {sql_harness-0.3.3 → sql_harness-0.3.5}/interaction-skills/aggregations-advanced.md +0 -0
- {sql_harness-0.3.3 → sql_harness-0.3.5}/interaction-skills/aggregations.md +0 -0
- {sql_harness-0.3.3 → sql_harness-0.3.5}/interaction-skills/encoding-and-charset.md +0 -0
- {sql_harness-0.3.3 → sql_harness-0.3.5}/interaction-skills/joins-advanced.md +0 -0
- {sql_harness-0.3.3 → sql_harness-0.3.5}/interaction-skills/joins.md +0 -0
- {sql_harness-0.3.3 → sql_harness-0.3.5}/interaction-skills/large-result-sets.md +0 -0
- {sql_harness-0.3.3 → sql_harness-0.3.5}/interaction-skills/migrations.md +0 -0
- {sql_harness-0.3.3 → sql_harness-0.3.5}/interaction-skills/mysql/json-columns.md +0 -0
- {sql_harness-0.3.3 → sql_harness-0.3.5}/interaction-skills/pooling.md +0 -0
- {sql_harness-0.3.3 → sql_harness-0.3.5}/interaction-skills/postgres/btree-indexes.md +0 -0
- {sql_harness-0.3.3 → sql_harness-0.3.5}/interaction-skills/postgres/indexes-and-explain.md +0 -0
- {sql_harness-0.3.3 → sql_harness-0.3.5}/interaction-skills/postgres/json-columns.md +0 -0
- {sql_harness-0.3.3 → sql_harness-0.3.5}/interaction-skills/postgres/migrations.md +0 -0
- {sql_harness-0.3.3 → sql_harness-0.3.5}/interaction-skills/postgres/plan-reading.md +0 -0
- {sql_harness-0.3.3 → sql_harness-0.3.5}/interaction-skills/postgres/slow-queries-joins.md +0 -0
- {sql_harness-0.3.3 → sql_harness-0.3.5}/interaction-skills/postgres/specialized-indexes.md +0 -0
- {sql_harness-0.3.3 → sql_harness-0.3.5}/interaction-skills/postgres/table-optimization.md +0 -0
- {sql_harness-0.3.3 → sql_harness-0.3.5}/interaction-skills/recursive-ctes-advanced.md +0 -0
- {sql_harness-0.3.3 → sql_harness-0.3.5}/interaction-skills/recursive-ctes.md +0 -0
- {sql_harness-0.3.3 → sql_harness-0.3.5}/interaction-skills/save-run-cycle.md +0 -0
- {sql_harness-0.3.3 → sql_harness-0.3.5}/interaction-skills/schema-introspection.md +0 -0
- {sql_harness-0.3.3 → sql_harness-0.3.5}/interaction-skills/ssh/auth-and-tunnels.md +0 -0
- {sql_harness-0.3.3 → sql_harness-0.3.5}/interaction-skills/ssh/docker-via-ssh.md +0 -0
- {sql_harness-0.3.3 → sql_harness-0.3.5}/interaction-skills/ssh/ssh.md +0 -0
- {sql_harness-0.3.3 → sql_harness-0.3.5}/interaction-skills/timeouts-and-cancellation.md +0 -0
- {sql_harness-0.3.3 → sql_harness-0.3.5}/interaction-skills/window-functions-advanced.md +0 -0
- {sql_harness-0.3.3 → sql_harness-0.3.5}/interaction-skills/window-functions.md +0 -0
- {sql_harness-0.3.3 → sql_harness-0.3.5}/interaction-skills/zone-skill-auto-surface.md +0 -0
- {sql_harness-0.3.3 → sql_harness-0.3.5}/sql-harness +0 -0
- {sql_harness-0.3.3 → sql_harness-0.3.5}/src/sql_harness/__init__.py +0 -0
- {sql_harness-0.3.3 → sql_harness-0.3.5}/src/sql_harness/agent_loader.py +0 -0
- {sql_harness-0.3.3 → sql_harness-0.3.5}/src/sql_harness/paths.py +0 -0
- {sql_harness-0.3.3 → sql_harness-0.3.5}/tests/__init__.py +0 -0
- {sql_harness-0.3.3 → sql_harness-0.3.5}/tests/conftest.py +0 -0
- {sql_harness-0.3.3 → sql_harness-0.3.5}/tests/integration/__init__.py +0 -0
- {sql_harness-0.3.3 → sql_harness-0.3.5}/tests/integration/test_mysql.py +0 -0
- {sql_harness-0.3.3 → sql_harness-0.3.5}/tests/integration/test_pg_optimization.py +0 -0
- {sql_harness-0.3.3 → sql_harness-0.3.5}/tests/integration/test_pgexercises_practice.py +0 -0
- {sql_harness-0.3.3 → sql_harness-0.3.5}/tests/integration/test_postgres.py +0 -0
- {sql_harness-0.3.3 → sql_harness-0.3.5}/tests/integration/test_ssh.py +0 -0
- {sql_harness-0.3.3 → sql_harness-0.3.5}/tests/unit/__init__.py +0 -0
- {sql_harness-0.3.3 → sql_harness-0.3.5}/tests/unit/test_agent_loader.py +0 -0
- {sql_harness-0.3.3 → sql_harness-0.3.5}/tests/unit/test_analytics.py +0 -0
- {sql_harness-0.3.3 → sql_harness-0.3.5}/tests/unit/test_config.py +0 -0
- {sql_harness-0.3.3 → sql_harness-0.3.5}/tests/unit/test_helpers.py +0 -0
- {sql_harness-0.3.3 → sql_harness-0.3.5}/tests/unit/test_manager.py +0 -0
- {sql_harness-0.3.3 → sql_harness-0.3.5}/tests/unit/test_paths.py +0 -0
- {sql_harness-0.3.3 → sql_harness-0.3.5}/tests/unit/test_ssh.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: sql-harness
|
|
3
|
-
Version: 0.3.
|
|
3
|
+
Version: 0.3.5
|
|
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
|
|
@@ -15,6 +15,7 @@ Classifier: Programming Language :: Python :: 3.12
|
|
|
15
15
|
Classifier: Topic :: Database
|
|
16
16
|
Classifier: Topic :: System :: Shells
|
|
17
17
|
Requires-Python: >=3.12
|
|
18
|
+
Requires-Dist: markdown-it-py<4,>=3
|
|
18
19
|
Requires-Dist: paramiko<4,>=3.4
|
|
19
20
|
Requires-Dist: psycopg[binary]<4,>=3.2
|
|
20
21
|
Requires-Dist: pymysql<2,>=1.1
|
|
@@ -37,7 +38,7 @@ uv sync
|
|
|
37
38
|
uv run sql-harness init
|
|
38
39
|
|
|
39
40
|
# 3. Add a connection
|
|
40
|
-
uv run sql-harness add local_pg --driver postgres --url 'postgresql://postgres:postgres@localhost:5432/postgres'
|
|
41
|
+
uv run sql-harness add --name local_pg --driver postgres --url 'postgresql://postgres:postgres@localhost:5432/postgres'
|
|
41
42
|
|
|
42
43
|
# 4. Test it
|
|
43
44
|
uv run sql-harness test local_pg
|
|
@@ -49,6 +50,9 @@ print(query("SELECT version()"))
|
|
|
49
50
|
print(list_tables())
|
|
50
51
|
print(describe("pg_class"))
|
|
51
52
|
PY
|
|
53
|
+
|
|
54
|
+
# 6. Browse + edit the docs (SKILL.md, interaction-skills/, your zone skills)
|
|
55
|
+
uv run sql-harness web
|
|
52
56
|
```
|
|
53
57
|
|
|
54
58
|
## Architecture (~1k lines across 8 core files)
|
|
@@ -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
|
|
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
|
|
31
|
-
sql-harness add <name> --driver ssh
|
|
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'
|
|
@@ -42,7 +42,7 @@ PY
|
|
|
42
42
|
- **First call requires `use_workspace(name)`** — no implicit default.
|
|
43
43
|
- Set `BH_SQL_ZONE_SKILLS=1` once so `use_workspace()` auto-surfaces that zone's per-DSN skill docs (see `interaction-skills/zone-skill-auto-surface.md`).
|
|
44
44
|
- For task-specific helpers, drop them into `agent-workspace/zones/<conn>/helpers.py` and they merge into the heredoc namespace (per-zone, overriding the shared base).
|
|
45
|
-
- Other subcommands: `init` (scaffold connections.toml + install preset zone skills), `doctor` (SELECT 1 on every connection), `edit`/`remove`/`show`, `workspace {list,use,close,show}`, `save`/`run`/`scripts`, `skill` (bare: emit packaged SKILL.md; `install`/`list`/`show`), `paths`, `open`, `stats`, `version`, `ssh {exec,upload,download,run-script,info}`.
|
|
45
|
+
- Other subcommands: `init` (scaffold connections.toml + install preset zone skills), `doctor` (SELECT 1 on every connection), `edit`/`remove`/`show`, `workspace {list,use,close,show}`, `save`/`run`/`scripts`, `skill` (bare: emit packaged SKILL.md; `install`/`list`/`show`), `paths`, `open`, `web` (browse + edit the whole doc library in a local web UI — shipped docs, runtime zones, saved scripts), `stats`, `version`, `ssh {exec,upload,download,run-script,info}`.
|
|
46
46
|
- Zone-scoped subcommands (`save`, `run`, `scripts`, `skill list/show`, `ssh`) need `-c <conn>` or `$BH_SQL_ACTIVE_CONNECTION`.
|
|
47
47
|
- **Analytics (ON by default)**: every CLI call and every doc read (`apply_skill`, `skill`, `skill show`) is counted into `$BH_SQL_HOME/analytics.json`; view with `sql-harness stats`. Every executed command (CLI argv or heredoc body) plus failures is appended to `$BH_SQL_HOME/sql-harness.log` (NDJSON, one record per line). Disable both with `BH_SQL_ANALYTICS=0|off|false|no`. Extension point: `from sql_harness.analytics import on, CLI_INVOKED, DOC_READ, COMMAND_EXECUTED` to attach custom listeners (observer pattern).
|
|
48
48
|
|
|
@@ -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
|
|
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,
|
|
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`.
|
|
@@ -189,6 +190,29 @@ Reached via `apply_skill("pool")` etc. from any active zone — `zones/meta/skil
|
|
|
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,12 +12,15 @@ 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
|
|
21
|
+
- `webapp.py` — `sql-harness web`: serves the doc library (shipped docs + runtime zones + saved scripts) over loopback with an in-page editor. `resolve_doc()` is the write boundary — read its docstring before touching path handling
|
|
19
22
|
- `run.py` — heredoc entry; delegates to CLI otherwise
|
|
20
|
-
- `cli.py` — argparse subparser CLI (
|
|
23
|
+
- `cli.py` — argparse subparser CLI (19 subcommands)
|
|
21
24
|
|
|
22
25
|
`SKILL.md` tells agents how to use the harness.
|
|
23
26
|
`docs/install.md` tells agents how to install and configure it.
|
|
@@ -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
|
|
@@ -26,6 +26,9 @@ print(query("SELECT version()"))
|
|
|
26
26
|
print(list_tables())
|
|
27
27
|
print(describe("pg_class"))
|
|
28
28
|
PY
|
|
29
|
+
|
|
30
|
+
# 6. Browse + edit the docs (SKILL.md, interaction-skills/, your zone skills)
|
|
31
|
+
uv run sql-harness web
|
|
29
32
|
```
|
|
30
33
|
|
|
31
34
|
## Architecture (~1k lines across 8 core files)
|
|
@@ -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
|
|
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"
|
|
@@ -89,7 +90,9 @@ password = "${env:IOT_SSH_PASSWORD}" # standalone secret — keeps the URL clea
|
|
|
89
90
|
|
|
90
91
|
`${env:VAR}` placeholders inside `url` and `password` are expanded at load time from the process environment — keep prod secrets out of the file.
|
|
91
92
|
|
|
92
|
-
Optional per-connection keys: `description`, `password`, `pool_size`, `pool_recycle`, `pre_ping`, `echo`, `application_name`.
|
|
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".
|
|
93
96
|
|
|
94
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`).
|
|
95
98
|
|
|
@@ -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
|
|
26
|
-
|
|
27
|
-
|
|
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 #
|
|
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
|
```
|
|
@@ -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.
|
|
3
|
+
version = "0.3.5"
|
|
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" }
|
|
@@ -24,6 +24,7 @@ dependencies = [
|
|
|
24
24
|
"pymysql>=1.1,<2",
|
|
25
25
|
"sqlalchemy>=2.0,<3",
|
|
26
26
|
"paramiko>=3.4,<4",
|
|
27
|
+
"markdown-it-py>=3,<4",
|
|
27
28
|
]
|
|
28
29
|
|
|
29
30
|
[project.scripts]
|
|
@@ -38,6 +39,10 @@ sql-harness = "sql_harness.run:main"
|
|
|
38
39
|
requires = ["hatchling"]
|
|
39
40
|
build-backend = "hatchling.build"
|
|
40
41
|
|
|
42
|
+
[[tool.uv.index]]
|
|
43
|
+
url = "https://pypi.tuna.tsinghua.edu.cn/simple"
|
|
44
|
+
default = true
|
|
45
|
+
|
|
41
46
|
[tool.hatch.build.targets.wheel]
|
|
42
47
|
# Mirrors browser-harness's setuptools src-layout. The package source is
|
|
43
48
|
# under `src/sql_harness/`; the package name (top-level import) is `sql_harness`.
|
|
@@ -50,6 +55,7 @@ packages = ["src/sql_harness"]
|
|
|
50
55
|
# shipping its SKILL.md in the wheel, but extended to the whole doc bundle.
|
|
51
56
|
# Paths are relative to this pyproject.toml (lab/sql_harness/).
|
|
52
57
|
"SKILL.md" = "sql_harness/_skills/SKILL.md"
|
|
58
|
+
"docs/install.md" = "sql_harness/_skills/install.md"
|
|
53
59
|
"interaction-skills" = "sql_harness/_skills/interaction-skills"
|
|
54
60
|
"agent-workspace/zones/meta/skills" = "sql_harness/_skills/zones-meta-skills"
|
|
55
61
|
|
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
/* sql-harness doc browser — docs-site styling, no framework. */
|
|
2
|
+
|
|
3
|
+
:root {
|
|
4
|
+
--bg: #ffffff;
|
|
5
|
+
--bg-soft: #f6f7f9;
|
|
6
|
+
--bg-side: #fafbfc;
|
|
7
|
+
--fg: #1f2328;
|
|
8
|
+
--fg-soft: #59636e;
|
|
9
|
+
--fg-faint: #818b98;
|
|
10
|
+
--line: #d8dee4;
|
|
11
|
+
--line-soft: #eaeef2;
|
|
12
|
+
--accent: #0969da;
|
|
13
|
+
--accent-soft: #ddf4ff;
|
|
14
|
+
--danger: #cf222e;
|
|
15
|
+
--ok: #1a7f37;
|
|
16
|
+
--code-bg: #f6f8fa;
|
|
17
|
+
--radius: 6px;
|
|
18
|
+
--mono: ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, "Liberation Mono", monospace;
|
|
19
|
+
--sans: -apple-system, BlinkMacSystemFont, "Segoe UI", "Noto Sans", Helvetica, Arial, sans-serif;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
@media (prefers-color-scheme: dark) {
|
|
23
|
+
:root {
|
|
24
|
+
--bg: #0d1117; --bg-soft: #161b22; --bg-side: #0f141b;
|
|
25
|
+
--fg: #e6edf3; --fg-soft: #9198a1; --fg-faint: #6e7681;
|
|
26
|
+
--line: #30363d; --line-soft: #21262d;
|
|
27
|
+
--accent: #4493f8; --accent-soft: #121d2f;
|
|
28
|
+
--danger: #f85149; --ok: #3fb950; --code-bg: #161b22;
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
* { box-sizing: border-box; }
|
|
33
|
+
|
|
34
|
+
html, body {
|
|
35
|
+
margin: 0; height: 100%;
|
|
36
|
+
background: var(--bg); color: var(--fg);
|
|
37
|
+
font-family: var(--sans); font-size: 15px; line-height: 1.65;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
.app { display: flex; height: 100vh; }
|
|
41
|
+
|
|
42
|
+
/* --- sidebar --- */
|
|
43
|
+
.sidebar {
|
|
44
|
+
width: 310px; flex: 0 0 310px; height: 100vh;
|
|
45
|
+
display: flex; flex-direction: column;
|
|
46
|
+
background: var(--bg-side); border-right: 1px solid var(--line);
|
|
47
|
+
}
|
|
48
|
+
.brand {
|
|
49
|
+
display: flex; align-items: baseline; gap: 8px;
|
|
50
|
+
padding: 16px 18px 12px; font-weight: 600;
|
|
51
|
+
}
|
|
52
|
+
.brand-name { font-size: 15px; letter-spacing: -0.01em; }
|
|
53
|
+
.brand-ver { font-size: 12px; color: var(--fg-faint); font-weight: 400; }
|
|
54
|
+
|
|
55
|
+
.filter {
|
|
56
|
+
margin: 0 12px 10px; padding: 7px 10px;
|
|
57
|
+
border: 1px solid var(--line); border-radius: var(--radius);
|
|
58
|
+
background: var(--bg); color: var(--fg); font-size: 13px; font-family: inherit;
|
|
59
|
+
}
|
|
60
|
+
.filter:focus { outline: none; border-color: var(--accent); }
|
|
61
|
+
|
|
62
|
+
.nav { flex: 1; overflow-y: auto; padding: 0 8px 24px; }
|
|
63
|
+
.group-title {
|
|
64
|
+
padding: 14px 10px 6px; font-size: 11px; font-weight: 600;
|
|
65
|
+
text-transform: uppercase; letter-spacing: 0.06em; color: var(--fg-faint);
|
|
66
|
+
}
|
|
67
|
+
.root-title {
|
|
68
|
+
display: flex; align-items: center; gap: 6px;
|
|
69
|
+
padding: 5px 10px; font-size: 13px; font-weight: 600; color: var(--fg-soft);
|
|
70
|
+
}
|
|
71
|
+
.badge {
|
|
72
|
+
font-size: 10px; font-weight: 500; padding: 1px 5px; border-radius: 20px;
|
|
73
|
+
background: var(--accent-soft); color: var(--accent);
|
|
74
|
+
}
|
|
75
|
+
.count { margin-left: auto; font-size: 11px; color: var(--fg-faint); font-weight: 400; }
|
|
76
|
+
|
|
77
|
+
.nav details { margin: 0; }
|
|
78
|
+
.nav summary {
|
|
79
|
+
cursor: pointer; list-style: none; padding: 3px 10px 3px 22px;
|
|
80
|
+
font-size: 13px; color: var(--fg-soft); border-radius: var(--radius);
|
|
81
|
+
}
|
|
82
|
+
.nav summary::-webkit-details-marker { display: none; }
|
|
83
|
+
.nav summary::before { content: "▸ "; color: var(--fg-faint); font-size: 10px; }
|
|
84
|
+
.nav details[open] > summary::before { content: "▾ "; }
|
|
85
|
+
.nav summary:hover { background: var(--bg-soft); }
|
|
86
|
+
|
|
87
|
+
.nav a {
|
|
88
|
+
display: block; padding: 3px 10px 3px 34px;
|
|
89
|
+
font-size: 13px; color: var(--fg-soft); text-decoration: none;
|
|
90
|
+
border-radius: var(--radius); overflow: hidden;
|
|
91
|
+
text-overflow: ellipsis; white-space: nowrap;
|
|
92
|
+
}
|
|
93
|
+
.nav a:hover { background: var(--bg-soft); color: var(--fg); }
|
|
94
|
+
.nav a.active { background: var(--accent-soft); color: var(--accent); font-weight: 500; }
|
|
95
|
+
.nav .empty { padding: 2px 10px 2px 34px; font-size: 12px; color: var(--fg-faint); }
|
|
96
|
+
|
|
97
|
+
.sidebar-foot {
|
|
98
|
+
padding: 10px 18px; border-top: 1px solid var(--line-soft);
|
|
99
|
+
font-size: 12px; color: var(--fg-faint); display: flex; align-items: center; gap: 6px;
|
|
100
|
+
}
|
|
101
|
+
.dot { width: 7px; height: 7px; border-radius: 50%; background: var(--ok); }
|
|
102
|
+
.dot.ro { background: var(--fg-faint); }
|
|
103
|
+
|
|
104
|
+
/* --- content --- */
|
|
105
|
+
.content { flex: 1; display: flex; flex-direction: column; min-width: 0; }
|
|
106
|
+
|
|
107
|
+
.doc-head {
|
|
108
|
+
display: flex; align-items: center; gap: 12px;
|
|
109
|
+
padding: 12px 32px; border-bottom: 1px solid var(--line-soft);
|
|
110
|
+
position: sticky; top: 0; background: var(--bg); z-index: 5;
|
|
111
|
+
}
|
|
112
|
+
.crumb {
|
|
113
|
+
font-family: var(--mono); font-size: 12px; color: var(--fg-faint);
|
|
114
|
+
overflow: hidden; text-overflow: ellipsis; white-space: nowrap; flex: 1;
|
|
115
|
+
}
|
|
116
|
+
.actions { display: flex; gap: 8px; flex: 0 0 auto; }
|
|
117
|
+
.actions button {
|
|
118
|
+
padding: 5px 12px; font-size: 13px; font-family: inherit;
|
|
119
|
+
border: 1px solid var(--line); border-radius: var(--radius);
|
|
120
|
+
background: var(--bg-soft); color: var(--fg); cursor: pointer;
|
|
121
|
+
}
|
|
122
|
+
.actions button:hover { border-color: var(--fg-faint); }
|
|
123
|
+
.actions button.primary { background: var(--accent); border-color: var(--accent); color: #fff; }
|
|
124
|
+
.actions button.primary:hover { filter: brightness(1.08); }
|
|
125
|
+
.actions button:disabled { opacity: 0.5; cursor: default; }
|
|
126
|
+
|
|
127
|
+
.markdown, .editor { flex: 1; overflow-y: auto; }
|
|
128
|
+
.markdown { padding: 28px 32px 80px; max-width: 860px; }
|
|
129
|
+
.editor {
|
|
130
|
+
margin: 0; padding: 20px 24px; border: none; resize: none;
|
|
131
|
+
background: var(--code-bg); color: var(--fg);
|
|
132
|
+
font-family: var(--mono); font-size: 13px; line-height: 1.6;
|
|
133
|
+
}
|
|
134
|
+
.editor:focus { outline: none; }
|
|
135
|
+
|
|
136
|
+
/* --- metadata panel --- */
|
|
137
|
+
.meta {
|
|
138
|
+
margin: 20px 32px -8px; padding: 12px 16px;
|
|
139
|
+
border: 1px solid var(--line); border-left: 3px solid var(--accent);
|
|
140
|
+
border-radius: var(--radius); background: var(--bg-soft);
|
|
141
|
+
font-size: 13px; max-width: 860px;
|
|
142
|
+
}
|
|
143
|
+
.meta dl { margin: 0; display: grid; grid-template-columns: 90px 1fr; gap: 4px 12px; }
|
|
144
|
+
.meta dt { color: var(--fg-faint); font-family: var(--mono); font-size: 12px; }
|
|
145
|
+
.meta dd { margin: 0; color: var(--fg-soft); word-break: break-word; }
|
|
146
|
+
|
|
147
|
+
/* --- status line --- */
|
|
148
|
+
.status {
|
|
149
|
+
padding: 8px 32px; font-size: 13px; border-top: 1px solid var(--line-soft);
|
|
150
|
+
background: var(--bg-soft);
|
|
151
|
+
}
|
|
152
|
+
.status.err { color: var(--danger); }
|
|
153
|
+
.status.ok { color: var(--ok); }
|
|
154
|
+
|
|
155
|
+
/* --- markdown --- */
|
|
156
|
+
.markdown h1, .markdown h2, .markdown h3, .markdown h4 {
|
|
157
|
+
line-height: 1.3; margin: 1.6em 0 0.6em; font-weight: 600; letter-spacing: -0.01em;
|
|
158
|
+
}
|
|
159
|
+
.markdown h1 { font-size: 1.9em; margin-top: 0; padding-bottom: 0.3em; border-bottom: 1px solid var(--line-soft); }
|
|
160
|
+
.markdown h2 { font-size: 1.4em; padding-bottom: 0.25em; border-bottom: 1px solid var(--line-soft); }
|
|
161
|
+
.markdown h3 { font-size: 1.15em; }
|
|
162
|
+
.markdown p, .markdown ul, .markdown ol { margin: 0.85em 0; }
|
|
163
|
+
.markdown ul, .markdown ol { padding-left: 1.6em; }
|
|
164
|
+
.markdown li { margin: 0.25em 0; }
|
|
165
|
+
.markdown a { color: var(--accent); text-decoration: none; }
|
|
166
|
+
.markdown a:hover { text-decoration: underline; }
|
|
167
|
+
.markdown code {
|
|
168
|
+
font-family: var(--mono); font-size: 0.87em;
|
|
169
|
+
background: var(--code-bg); padding: 0.15em 0.4em; border-radius: 4px;
|
|
170
|
+
}
|
|
171
|
+
.markdown pre {
|
|
172
|
+
background: var(--code-bg); padding: 14px 16px; border-radius: var(--radius);
|
|
173
|
+
overflow-x: auto; border: 1px solid var(--line-soft);
|
|
174
|
+
}
|
|
175
|
+
.markdown pre code { background: none; padding: 0; font-size: 12.5px; line-height: 1.55; }
|
|
176
|
+
.markdown blockquote {
|
|
177
|
+
margin: 0.9em 0; padding: 0 1em; color: var(--fg-soft);
|
|
178
|
+
border-left: 3px solid var(--line);
|
|
179
|
+
}
|
|
180
|
+
.markdown table {
|
|
181
|
+
border-collapse: collapse; margin: 1em 0; display: block; overflow-x: auto; max-width: 100%;
|
|
182
|
+
}
|
|
183
|
+
.markdown th, .markdown td { border: 1px solid var(--line); padding: 6px 12px; text-align: left; }
|
|
184
|
+
.markdown th { background: var(--bg-soft); font-weight: 600; }
|
|
185
|
+
.markdown hr { border: none; border-top: 1px solid var(--line-soft); margin: 2em 0; }
|
|
186
|
+
|
|
187
|
+
@media (max-width: 820px) {
|
|
188
|
+
.app { flex-direction: column; }
|
|
189
|
+
.sidebar { width: 100%; flex: 0 0 45vh; height: 45vh; border-right: none; border-bottom: 1px solid var(--line); }
|
|
190
|
+
.markdown, .doc-head, .status, .meta { padding-left: 16px; padding-right: 16px; margin-left: 0; margin-right: 0; }
|
|
191
|
+
}
|