sql-harness 0.1.0__py3-none-any.whl

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/cli.py ADDED
@@ -0,0 +1,869 @@
1
+ """sql-harness CLI — argparse subparsers + cmd_* dispatch.
2
+
3
+ Subcommands:
4
+ list Show all configured connections.
5
+ add [--name N --driver D --url U ...] Add a connection (interactive if missing flags).
6
+ edit <name> Open connections.toml in $EDITOR.
7
+ remove <name> Remove a connection.
8
+ show <name> Print details (password masked).
9
+ test <name> Open engine, SELECT 1, close; report latency.
10
+ workspace list List open workspaces in this process (usually empty for CLI).
11
+ workspace use <name> Print the masked connection details; signals intent.
12
+ workspace close <name> Close + dispose an engine (no-op if not open).
13
+ workspace show <name> Same as `show`.
14
+ skill list List available agent-workspace skills.
15
+ skill show <name> Print the skill markdown.
16
+ save <name> Persist stdin (a heredoc) as agent-workspace/scripts/<name>.py.
17
+ run <name> Execute a saved script (scripts/<name>.py) in the helpers namespace.
18
+ scripts List saved scripts.
19
+ init Write a starter connections.toml if none exists.
20
+ version Print sql-harness version + driver versions.
21
+ doctor Sanity-check config + try SELECT 1 on each connection.
22
+
23
+ Mirrors `lab/subprocess/cli.py` style.
24
+ """
25
+
26
+ from __future__ import annotations
27
+
28
+ import argparse
29
+ import json
30
+ import os
31
+ import shutil
32
+ import subprocess
33
+ import sys
34
+ import time
35
+ from importlib import resources as importlib_resources
36
+ from pathlib import Path
37
+ from typing import Callable
38
+
39
+ from sqlalchemy import text
40
+
41
+ from . import __version__
42
+ from .config import (
43
+ ConnectionConfig,
44
+ PoolConfig,
45
+ load as load_config,
46
+ save as save_config,
47
+ )
48
+ from .manager import SqlHarness
49
+ from .paths import (
50
+ config_file,
51
+ ensure_private_dir,
52
+ home_dir,
53
+ scripts_dir,
54
+ workspace_dir,
55
+ zone_dir,
56
+ zone_scripts_dir,
57
+ zone_skills_dir,
58
+ )
59
+
60
+
61
+ # --- Output helpers --------------------------------------------------------
62
+
63
+ def _emit(data) -> None:
64
+ """Print structured data as JSON; plain text otherwise."""
65
+ if isinstance(data, (dict, list)):
66
+ print(json.dumps(data, indent=2, ensure_ascii=False, default=str))
67
+ else:
68
+ print(data)
69
+
70
+
71
+ # --- Subcommand implementations --------------------------------------------
72
+
73
+ def cmd_list(_args, harness: SqlHarness) -> int:
74
+ cfg = harness.config
75
+ if not cfg.connections:
76
+ _emit({"connections": [], "hint": "run `sql-harness init` to scaffold one"})
77
+ return 0
78
+ rows = []
79
+ for c in cfg.connections:
80
+ rows.append({
81
+ "name": c.name,
82
+ "driver": c.driver,
83
+ "url": c.masked_url(),
84
+ "description": c.description,
85
+ "pool_size": c.pool.size,
86
+ })
87
+ _emit({"default_workspace": cfg.default_workspace, "connections": rows})
88
+ return 0
89
+
90
+
91
+ def cmd_add(args, harness: SqlHarness) -> int:
92
+ cfg = harness.config
93
+ # Pull from flags or prompt.
94
+ name = args.name or input("connection name: ").strip()
95
+ if not name:
96
+ print("error: name required", file=sys.stderr)
97
+ return 2
98
+ if any(c.name == name for c in cfg.connections):
99
+ print(f"error: connection {name!r} already exists", file=sys.stderr)
100
+ return 2
101
+
102
+ driver = args.driver or input("driver (postgres|mysql|redis|sqlite|ssh): ").strip()
103
+ if driver not in ("postgres", "mysql", "redis", "sqlite", "ssh", "ssh+password", "ssh+key"):
104
+ print(f"error: unknown driver {driver!r}", file=sys.stderr)
105
+ return 2
106
+
107
+ url = args.url or input("URL: ").strip()
108
+ if not url:
109
+ print("error: url required", file=sys.stderr)
110
+ return 2
111
+
112
+ description = args.description or ""
113
+ pool = PoolConfig(size=args.pool_size or cfg.pool_defaults.size)
114
+
115
+ cfg.connections.append(
116
+ ConnectionConfig(
117
+ name=name,
118
+ driver=driver,
119
+ url=url,
120
+ description=description,
121
+ pool=pool,
122
+ )
123
+ )
124
+ save_config(cfg, config_file())
125
+ _emit({"added": name, "driver": driver, "url": _mask(url)})
126
+ return 0
127
+
128
+
129
+ def cmd_edit(args, _harness: SqlHarness) -> int:
130
+ path = config_file()
131
+ if not path.exists():
132
+ print(f"error: {path} does not exist; run `sql-harness init` first", file=sys.stderr)
133
+ return 2
134
+ editor = os.environ.get("EDITOR") or ("notepad" if sys.platform == "win32" else "vi")
135
+ if _harness_contains(_harness_name_filter(args.name), load_config(path)):
136
+ pass
137
+ return subprocess.call([editor, str(path)])
138
+
139
+
140
+ def _harness_contains(name: str, cfg) -> bool:
141
+ return any(c.name == name for c in cfg.connections)
142
+
143
+
144
+ def _harness_name_filter(_name: str | None) -> str:
145
+ return _name or ""
146
+
147
+
148
+ def cmd_remove(args, harness: SqlHarness) -> int:
149
+ cfg = harness.config
150
+ before = len(cfg.connections)
151
+ cfg.connections = [c for c in cfg.connections if c.name != args.name]
152
+ if len(cfg.connections) == before:
153
+ print(f"error: no connection named {args.name!r}", file=sys.stderr)
154
+ return 2
155
+ if cfg.default_workspace == args.name:
156
+ cfg.default_workspace = ""
157
+ save_config(cfg, config_file())
158
+ _emit({"removed": args.name})
159
+ return 0
160
+
161
+
162
+ def cmd_show(args, harness: SqlHarness) -> int:
163
+ try:
164
+ c = harness.config.get(args.name)
165
+ except KeyError:
166
+ print(f"error: no connection named {args.name!r}", file=sys.stderr)
167
+ return 2
168
+ _emit({
169
+ "name": c.name,
170
+ "driver": c.driver,
171
+ "url": c.masked_url(),
172
+ "description": c.description,
173
+ "pool": {
174
+ "size": c.pool.size,
175
+ "recycle": c.pool.recycle,
176
+ "pre_ping": c.pool.pre_ping,
177
+ "echo": c.pool.echo,
178
+ },
179
+ "application_name": c.application_name,
180
+ })
181
+ return 0
182
+
183
+
184
+ def cmd_test(args, harness: SqlHarness) -> int:
185
+ name = args.name
186
+ try:
187
+ ws = harness.workspace(name)
188
+ except KeyError:
189
+ print(f"error: no connection named {name!r}", file=sys.stderr)
190
+ return 2
191
+ except Exception as e:
192
+ _emit({"name": name, "ok": False, "error": str(e)})
193
+ return 1
194
+
195
+ t0 = time.monotonic()
196
+ try:
197
+ with ws.engine.connect() as conn:
198
+ row = conn.execute(text("SELECT 1")).scalar()
199
+ dt = (time.monotonic() - t0) * 1000
200
+ _emit({"name": name, "ok": True, "select_1": row, "latency_ms": round(dt, 2)})
201
+ return 0
202
+ except Exception as e:
203
+ _emit({"name": name, "ok": False, "error": str(e)})
204
+ return 1
205
+
206
+
207
+ def cmd_workspace(args, harness: SqlHarness) -> int:
208
+ sub = args.workspace_cmd
209
+ if sub == "list":
210
+ _emit({"open": harness.list_workspaces()})
211
+ return 0
212
+ if sub == "use":
213
+ try:
214
+ ws = harness.workspace(args.name)
215
+ except KeyError:
216
+ print(f"error: no connection named {args.name!r}", file=sys.stderr)
217
+ return 2
218
+ _emit({"name": ws.config.connection.name, "driver": ws.driver.name, "ok": True})
219
+ return 0
220
+ if sub == "close":
221
+ if not harness.has_workspace(args.name):
222
+ print(f"info: workspace {args.name!r} was not open", file=sys.stderr)
223
+ return 0
224
+ harness.close_workspace(args.name)
225
+ _emit({"closed": args.name})
226
+ return 0
227
+ if sub == "show":
228
+ return cmd_show(argparse.Namespace(name=args.name), harness)
229
+ print(f"unknown workspace subcommand: {sub}", file=sys.stderr)
230
+ return 2
231
+
232
+
233
+ def _resolve_connection(args) -> str | None:
234
+ """Resolve the active DSN zone for a zone-scoped CLI command.
235
+
236
+ Order: explicit `--connection` flag > $BH_SQL_ACTIVE_CONNECTION env >
237
+ None (caller errors with guidance).
238
+ """
239
+ conn = getattr(args, "connection", None)
240
+ if conn:
241
+ return conn
242
+ return os.environ.get("BH_SQL_ACTIVE_CONNECTION")
243
+
244
+
245
+ def _require_connection_or_fail(args) -> tuple[str | None, int]:
246
+ """Return (connection, exit_code). If exit_code is set, connection is None."""
247
+ conn = _resolve_connection(args)
248
+ if not conn:
249
+ print(
250
+ "error: no active connection. Pass --connection NAME, set "
251
+ "BH_SQL_ACTIVE_CONNECTION, or call use_workspace(name) first.",
252
+ file=sys.stderr,
253
+ )
254
+ return None, 2
255
+ return conn, 0
256
+
257
+
258
+ def cmd_skill(args, _harness: SqlHarness) -> int:
259
+ sub = args.skill_cmd
260
+ # Bare `sql-harness skill` (no subcommand) emits the packaged SKILL.md
261
+ # to stdout — mirrors `browser-harness skill`. Used for agent registration:
262
+ # sql-harness skill > ~/.codex/skills/sql-harness/SKILL.md
263
+ if sub is None:
264
+ body = _packaged_skill_body()
265
+ if body is None:
266
+ print(
267
+ "error: packaged SKILL.md not found (expected at "
268
+ "skills/sql-harness/SKILL.md relative to the package)",
269
+ file=sys.stderr,
270
+ )
271
+ return 2
272
+ # Force UTF-8 on stdout so non-ASCII skill bodies emit cleanly on Windows
273
+ # (default GBK/CP936 codepage can't encode symbols like ⇄).
274
+ out = sys.stdout
275
+ try:
276
+ out.reconfigure(encoding="utf-8", errors="replace")
277
+ except (AttributeError, OSError):
278
+ pass
279
+ out.write(body)
280
+ if not body.endswith("\n"):
281
+ out.write("\n")
282
+ return 0
283
+ # `skill list` / `skill show` are zone-scoped (per-DSN, like domain-skills).
284
+ conn, code = _require_connection_or_fail(args)
285
+ if code:
286
+ return code
287
+ if sub == "list":
288
+ d = zone_skills_dir(conn)
289
+ zone = d.glob("*.md") if d.is_dir() else []
290
+ # Also include global skills (shipped examples) as fallback.
291
+ global_d = workspace_dir() / "skills"
292
+ global_ = global_d.glob("*.md") if global_d.is_dir() else []
293
+ seen = set()
294
+ names = []
295
+ for p in sorted(zone) + sorted(global_):
296
+ if p.stem not in seen:
297
+ seen.add(p.stem)
298
+ names.append(p.stem)
299
+ _emit({"connection": conn, "skills": names})
300
+ return 0
301
+ if sub == "show":
302
+ zone_path = zone_skills_dir(conn) / f"{args.name}.md"
303
+ global_path = workspace_dir() / "skills" / f"{args.name}.md"
304
+ if zone_path.is_file():
305
+ print(zone_path.read_text(encoding="utf-8"))
306
+ elif global_path.is_file():
307
+ print(global_path.read_text(encoding="utf-8"))
308
+ else:
309
+ print(
310
+ f"error: no skill named {args.name!r} in zone {conn!r} or global",
311
+ file=sys.stderr,
312
+ )
313
+ return 2
314
+ return 0
315
+ print(f"unknown skill subcommand: {sub}", file=sys.stderr)
316
+ return 2
317
+
318
+
319
+ def _package_root_dir() -> Path:
320
+ """The sql-harness container dir (lab/sql_harness/), 3 levels above cli.py.
321
+
322
+ cli.py lives at <root>/src/sql_harness/cli.py, so parents[2] = <root>.
323
+ Holds repo-level content (agent-workspace/, skills/, interaction-skills/)
324
+ that ships with the source checkout but is not inside the importable package.
325
+ """
326
+ return Path(__file__).resolve().parents[2]
327
+
328
+
329
+ def _shipped_skills_dir() -> Path:
330
+ """Shipped preset skills: <root>/agent-workspace/skills/."""
331
+ return _package_root_dir() / "agent-workspace" / "skills"
332
+
333
+
334
+ def _packaged_skill_body() -> str | None:
335
+ """Return the in-package SKILL.md body, or None if not found.
336
+
337
+ Mirrors browser-harness's run.py:_print_skill, which reads the packaged
338
+ SKILL.md via importlib.resources so it works from both a source checkout
339
+ and a built wheel. Falls back to the repo-level skills/sql-harness/SKILL.md.
340
+ """
341
+ # 1. in-package: src/sql_harness/SKILL.md (force-included in the wheel)
342
+ try:
343
+ ref = importlib_resources.files("sql_harness").joinpath("SKILL.md")
344
+ if ref.is_file():
345
+ return ref.read_text(encoding="utf-8")
346
+ except (ModuleNotFoundError, FileNotFoundError):
347
+ pass
348
+ # 2. repo-level fallback (source checkout)
349
+ repo = _package_root_dir() / "skills" / "sql-harness" / "SKILL.md"
350
+ if repo.is_file():
351
+ return repo.read_text(encoding="utf-8")
352
+ return None
353
+
354
+
355
+ def _copy_shipped_skills(target_dir: Path) -> list[str]:
356
+ """Copy every shipped preset skill (.md) into target_dir if not present.
357
+
358
+ Returns the list of skill names (stems) now present in target_dir.
359
+ """
360
+ src = _shipped_skills_dir()
361
+ if not src.is_dir():
362
+ return []
363
+ present: list[str] = []
364
+ for f in sorted(src.glob("*.md")):
365
+ dst = target_dir / f.name
366
+ if not dst.exists():
367
+ shutil.copy2(str(f), str(dst))
368
+ present.append(f.stem)
369
+ return present
370
+
371
+
372
+ def cmd_init(_args, _harness: SqlHarness) -> int:
373
+ """Scaffold a starter connections.toml if none exists.
374
+
375
+ Also copies shipped preset skills into the runtime workspace so they're
376
+ reachable via `apply_skill()` from any zone — mirrors browser-harness's
377
+ skill-registration pattern.
378
+ """
379
+ path = config_file()
380
+ ensure_private_dir(home_dir())
381
+ if not path.exists():
382
+ from .config import example_config
383
+
384
+ save_config(example_config(), path)
385
+ _emit({"path": str(path), "existed": False, "wrote": "example_config"})
386
+ else:
387
+ _emit({"path": str(path), "existed": True})
388
+
389
+ target_skills = workspace_dir() / "skills"
390
+ ensure_private_dir(target_skills)
391
+ copied = _copy_shipped_skills(target_skills)
392
+ if copied:
393
+ print(
394
+ f"installed {len(copied)} preset skills: {', '.join(copied)}",
395
+ file=sys.stderr,
396
+ )
397
+ return 0
398
+
399
+
400
+ def cmd_version(_args, _harness: SqlHarness) -> int:
401
+ import importlib.metadata
402
+
403
+ info = {"sql-harness": __version__}
404
+ for pkg in ("psycopg", "pymysql", "sqlalchemy"):
405
+ try:
406
+ info[pkg] = importlib.metadata.version(pkg)
407
+ except importlib.metadata.PackageNotFoundError:
408
+ info[pkg] = "(not installed)"
409
+ _emit(info)
410
+ return 0
411
+
412
+
413
+ def cmd_doctor(_args, harness: SqlHarness) -> int:
414
+ """Sanity-check: list connections + try SELECT 1 on each."""
415
+ rows = []
416
+ for c in harness.config.connections:
417
+ try:
418
+ ws = harness.workspace(c.name)
419
+ t0 = time.monotonic()
420
+ with ws.engine.connect() as conn:
421
+ conn.execute(text("SELECT 1")).scalar()
422
+ dt = (time.monotonic() - t0) * 1000
423
+ rows.append({"name": c.name, "ok": True, "latency_ms": round(dt, 2)})
424
+ except Exception as e:
425
+ rows.append({"name": c.name, "ok": False, "error": str(e)})
426
+ _emit({"results": rows})
427
+ return 0 if all(r["ok"] for r in rows) else 1
428
+
429
+
430
+ def _validate_script_name(name: str) -> None:
431
+ """Reject path-traversal / weird names for saved scripts."""
432
+ if not name or "/" in name or "\\" in name or name.startswith("."):
433
+ raise ValueError(f"invalid script name: {name!r}")
434
+ if not name.replace("_", "").replace("-", "").isalnum():
435
+ raise ValueError(f"invalid script name: {name!r} (letters/digits/_/- only)")
436
+
437
+
438
+ def cmd_save(args, _harness: SqlHarness) -> int:
439
+ """Persist stdin (a heredoc body) as zones/<connection>/scripts/<name>.py.
440
+
441
+ This is the essence of the harness: executed code is saved as reusable
442
+ Python, scoped to the DSN it was written for (mirrors browser-harness's
443
+ per-domain domain-skills). Next session, `sql-harness run <name>` (with
444
+ the same --connection) re-executes it.
445
+ """
446
+ try:
447
+ _validate_script_name(args.name)
448
+ except ValueError as e:
449
+ print(f"error: {e}", file=sys.stderr)
450
+ return 2
451
+ conn, code = _require_connection_or_fail(args)
452
+ if code:
453
+ return code
454
+ body = sys.stdin.read()
455
+ if not body.strip():
456
+ print("error: stdin is empty; nothing to save", file=sys.stderr)
457
+ return 2
458
+ target_dir = zone_scripts_dir(conn)
459
+ ensure_private_dir(target_dir)
460
+ target = target_dir / f"{args.name}.py"
461
+ existed = target.exists()
462
+ # Prepend a provenance header so the file is self-describing.
463
+ header = (
464
+ '"""Saved by `sql-harness save ' + args.name + '` (connection: ' + conn + ').\n'
465
+ "Re-run with: sql-harness run " + args.name + " --connection " + conn + "\n"
466
+ "Helpers (use_workspace, query, execute, ...) are pre-imported.\n"
467
+ '"""\n'
468
+ )
469
+ target.write_text(header + body, encoding="utf-8")
470
+ _emit({
471
+ "saved": args.name,
472
+ "connection": conn,
473
+ "path": str(target),
474
+ "existed": existed,
475
+ "bytes": len(body),
476
+ })
477
+ return 0
478
+
479
+
480
+ def cmd_run(args, harness: SqlHarness) -> int:
481
+ """Execute a saved script (zones/<connection>/scripts/<name>.py).
482
+
483
+ The script runs with all helpers pre-imported, exactly like heredoc mode.
484
+ """
485
+ try:
486
+ _validate_script_name(args.name)
487
+ except ValueError as e:
488
+ print(f"error: {e}", file=sys.stderr)
489
+ return 2
490
+ conn, code = _require_connection_or_fail(args)
491
+ if code:
492
+ return code
493
+ path = zone_scripts_dir(conn) / f"{args.name}.py"
494
+ if not path.is_file():
495
+ print(
496
+ f"error: no saved script named {args.name!r} in zone {conn!r} "
497
+ f"({zone_scripts_dir(conn)})",
498
+ file=sys.stderr,
499
+ )
500
+ return 2
501
+ # Build the same exec namespace run.py uses for heredocs.
502
+ from . import helpers
503
+ from .helpers import set_active, use_workspace
504
+
505
+ set_active(harness)
506
+ # Activate the connection's zone so zone helpers + skills resolve.
507
+ use_workspace(conn)
508
+ namespace: dict = {}
509
+ for n, v in vars(helpers).items():
510
+ if not n.startswith("_"):
511
+ namespace[n] = v
512
+ code_text = path.read_text(encoding="utf-8")
513
+ try:
514
+ exec(compile(code_text, str(path), "exec"), namespace)
515
+ return 0
516
+ except SystemExit as e:
517
+ return int(e.code) if e.code is not None else 0
518
+ except Exception:
519
+ import traceback
520
+
521
+ traceback.print_exc()
522
+ return 1
523
+
524
+
525
+ def cmd_scripts(args, _harness: SqlHarness) -> int:
526
+ """List saved scripts in a connection's zone."""
527
+ conn, code = _require_connection_or_fail(args)
528
+ if code:
529
+ return code
530
+ d = zone_scripts_dir(conn)
531
+ names = sorted(p.stem for p in d.glob("*.py")) if d.is_dir() else []
532
+ _emit({"connection": conn, "scripts": names, "dir": str(d)})
533
+ return 0
534
+
535
+
536
+ def _path_record(name: str, path: Path) -> dict:
537
+ """Build a {name, path, exists} record for the paths listing."""
538
+ return {"name": name, "path": str(path), "exists": path.exists()}
539
+
540
+
541
+ def cmd_paths(args, harness: SqlHarness) -> int:
542
+ """Print every folder/file the CLI involves, so you can open them.
543
+
544
+ Global paths always shown; pass `-c CONN` to also show that DSN's zone
545
+ (scripts / skills / per-connection helpers). Mirrors the "where does my
546
+ stuff live" ergonomics browser-harness gives via its state-dir layout.
547
+ """
548
+ from . import paths as P
549
+ from importlib import resources as importlib_resources
550
+
551
+ pkg_src = Path(str(importlib_resources.files("sql_harness"))).resolve()
552
+ records: list[dict] = [
553
+ _path_record("home", P.home_dir()),
554
+ _path_record("config_dir", P.config_dir()),
555
+ _path_record("config_file", P.config_file()),
556
+ _path_record("agent_workspace", P.workspace_dir()),
557
+ _path_record("global_skills", P.workspace_dir() / "skills"),
558
+ _path_record("runtime_dir", P.runtime_dir()),
559
+ _path_record("tmp_dir", P.tmp_dir()),
560
+ _path_record("package_root", _package_root_dir()),
561
+ _path_record("package_source", pkg_src),
562
+ ]
563
+ out: dict = {
564
+ "global": records,
565
+ "env": {
566
+ "BH_SQL_HOME": os.environ.get("BH_SQL_HOME", ""),
567
+ "BH_SQL_CONFIG_FILE": os.environ.get("BH_SQL_CONFIG_FILE", ""),
568
+ "BH_SQL_AGENT_WORKSPACE": os.environ.get("BH_SQL_AGENT_WORKSPACE", ""),
569
+ },
570
+ "connections": harness.config.names(),
571
+ "default_workspace": harness.config.default_workspace,
572
+ }
573
+ conn = _resolve_connection(args)
574
+ if conn:
575
+ out["zone"] = {
576
+ "connection": conn,
577
+ "dirs": [
578
+ _path_record("zone_dir", P.zone_dir(conn)),
579
+ _path_record("zone_scripts", zone_scripts_dir(conn)),
580
+ _path_record("zone_skills", zone_skills_dir(conn)),
581
+ _path_record("zone_helpers", P.zone_helpers_file(conn)),
582
+ ],
583
+ }
584
+ _emit(out)
585
+ return 0
586
+
587
+
588
+ _OPEN_TARGETS = ("home", "config", "workspace", "scripts", "skills", "runtime", "tmp", "package", "source", "zone")
589
+
590
+
591
+ def _resolve_open_path(target: str, conn: str | None) -> Path:
592
+ """Map an open-target name to a concrete directory."""
593
+ from . import paths as P
594
+
595
+ table = {
596
+ "home": P.home_dir(),
597
+ "config": P.config_dir(),
598
+ "workspace": P.workspace_dir(),
599
+ "skills": P.workspace_dir() / "skills",
600
+ "runtime": P.runtime_dir(),
601
+ "tmp": P.tmp_dir(),
602
+ "package": _package_root_dir(),
603
+ "source": Path(str(importlib_resources.files("sql_harness"))).resolve(),
604
+ }
605
+ if target in table:
606
+ return table[target]
607
+ if target == "scripts":
608
+ if not conn:
609
+ raise ValueError("scripts is zone-scoped; pass --connection NAME")
610
+ return zone_scripts_dir(conn)
611
+ if target == "zone":
612
+ if not conn:
613
+ raise ValueError("zone is connection-scoped; pass --connection NAME")
614
+ return P.zone_dir(conn)
615
+ raise ValueError(f"unknown target {target!r}; one of: {', '.join(_OPEN_TARGETS)}")
616
+
617
+
618
+ def _reveal(path: Path) -> None:
619
+ """Open `path` in the OS file manager (create it first if missing)."""
620
+ path.mkdir(parents=True, exist_ok=True)
621
+ if sys.platform == "win32":
622
+ os.startfile(str(path)) # type: ignore[attr-defined]
623
+ elif sys.platform == "darwin":
624
+ subprocess.Popen(["open", str(path)])
625
+ else:
626
+ subprocess.Popen(["xdg-open", str(path)])
627
+
628
+
629
+ def cmd_open(args, _harness: SqlHarness) -> int:
630
+ """Open a folder in your OS file manager.
631
+
632
+ `sql-harness open workspace` → agent-workspace
633
+ `sql-harness open scripts -c test_pg` → that zone's saved scripts
634
+ `sql-harness open config` → connections.toml folder
635
+ """
636
+ from importlib import resources as importlib_resources # noqa: F401
637
+
638
+ target = args.target or "workspace"
639
+ conn = _resolve_connection(args)
640
+ try:
641
+ path = _resolve_open_path(target, conn)
642
+ except ValueError as e:
643
+ print(f"error: {e}", file=sys.stderr)
644
+ return 2
645
+ _reveal(path)
646
+ _emit({"opened": target, "path": str(path), "connection": conn})
647
+ return 0
648
+
649
+
650
+ # --- Helpers ---------------------------------------------------------------
651
+
652
+ def _mask(url: str) -> str:
653
+ if "@" not in url:
654
+ return url
655
+ head, tail = url.split("@", 1)
656
+ if "://" not in head:
657
+ return url
658
+ scheme, creds = head.split("://", 1)
659
+ if ":" in creds:
660
+ user, _ = creds.split(":", 1)
661
+ return f"{scheme}://{user}:***@{tail}"
662
+ return url
663
+
664
+
665
+ def _activate_ssh_zone(harness: SqlHarness, conn_name: str | None) -> None:
666
+ """Activate the SSH workspace (sets helpers._active_connection + opens workspace)."""
667
+ from . import helpers as _h
668
+ from .helpers import set_active, use_workspace
669
+
670
+ set_active(harness)
671
+ name = conn_name or harness.config.default_workspace
672
+ if not name:
673
+ raise RuntimeError(
674
+ "no SSH connection specified. Pass --connection NAME or set "
675
+ "default_workspace in connections.toml."
676
+ )
677
+ if name not in harness.config.names():
678
+ raise RuntimeError(f"no connection named {name!r}")
679
+ use_workspace(name)
680
+
681
+
682
+ def cmd_ssh(args, harness: SqlHarness) -> int:
683
+ """Dispatch `sql-harness ssh {exec,upload,download,run-script,info}`."""
684
+ from . import helpers as _h
685
+
686
+ sub = getattr(args, "ssh_cmd", None)
687
+ if sub is None:
688
+ print("error: `ssh` needs a subcommand: exec / upload / download / run-script / info",
689
+ file=sys.stderr)
690
+ return 2
691
+
692
+ conn, code = _require_connection_or_fail(args)
693
+ if code:
694
+ return code
695
+ try:
696
+ _activate_ssh_zone(harness, conn)
697
+ except Exception as e:
698
+ print(f"error: {e}", file=sys.stderr)
699
+ return 2
700
+
701
+ if sub == "exec":
702
+ result = _h.ssh_exec(args.command, timeout=args.timeout)
703
+ sys.stdout.write(result["stdout"])
704
+ if result["stderr"]:
705
+ sys.stderr.write(result["stderr"])
706
+ return 0 if result["ok"] else result["exit_code"]
707
+
708
+ if sub == "upload":
709
+ _h.ssh_upload(args.local, args.remote)
710
+ _emit({"uploaded": args.local, "to": args.remote})
711
+ return 0
712
+
713
+ if sub == "download":
714
+ _h.ssh_download(args.remote, args.local)
715
+ _emit({"downloaded": args.remote, "to": args.local})
716
+ return 0
717
+
718
+ if sub == "run-script":
719
+ result = _h.ssh_run_script(args.local, args.remote_dir, args.interpreter)
720
+ sys.stdout.write(result["stdout"])
721
+ if result["stderr"]:
722
+ sys.stderr.write(result["stderr"])
723
+ return 0 if result["ok"] else result["exit_code"]
724
+
725
+ if sub == "info":
726
+ _emit(_h.ssh_info())
727
+ return 0
728
+
729
+ print(f"error: unknown ssh subcommand: {sub}", file=sys.stderr)
730
+ return 2
731
+
732
+
733
+ # --- Parser & dispatch -----------------------------------------------------
734
+
735
+ def _build_parser() -> argparse.ArgumentParser:
736
+ p = argparse.ArgumentParser(
737
+ prog="sql-harness",
738
+ description="Single-process SQL CLI for LLM agents (postgres, mysql, redis-soon).",
739
+ )
740
+ sub = p.add_subparsers(dest="cmd")
741
+
742
+ sub.add_parser("list", help="show all configured connections")
743
+
744
+ p_add = sub.add_parser("add", help="add a connection")
745
+ p_add.add_argument("--name")
746
+ p_add.add_argument("--driver", choices=["postgres", "mysql", "redis", "sqlite", "ssh"])
747
+ p_add.add_argument("--url")
748
+ p_add.add_argument("--description")
749
+ p_add.add_argument("--pool-size", type=int)
750
+
751
+ p_edit = sub.add_parser("edit", help="edit connections.toml in $EDITOR")
752
+ p_edit.add_argument("name", nargs="?")
753
+
754
+ p_remove = sub.add_parser("remove", help="remove a connection")
755
+ p_remove.add_argument("name")
756
+
757
+ p_show = sub.add_parser("show", help="show connection details (password masked)")
758
+ p_show.add_argument("name")
759
+
760
+ p_test = sub.add_parser("test", help="connect + SELECT 1")
761
+ p_test.add_argument("name")
762
+
763
+ p_ws = sub.add_parser("workspace", help="manage workspaces")
764
+ ws_sub = p_ws.add_subparsers(dest="workspace_cmd")
765
+ ws_sub.add_parser("list", help="list open workspaces")
766
+ p_ws_use = ws_sub.add_parser("use", help="open a workspace")
767
+ p_ws_use.add_argument("name")
768
+ p_ws_close = ws_sub.add_parser("close", help="close a workspace")
769
+ p_ws_close.add_argument("name")
770
+ ws_sub.add_parser("show", help="alias for `show`")
771
+
772
+ p_skill = sub.add_parser(
773
+ "skill",
774
+ help="emit packaged SKILL.md (bare) or manage per-DSN zone skills",
775
+ )
776
+ p_skill.add_argument("-c", "--connection", help="DSN zone (for list/show)")
777
+ skill_sub = p_skill.add_subparsers(dest="skill_cmd")
778
+ skill_sub.add_parser("list", help="list skills in the zone")
779
+ p_skill_show = skill_sub.add_parser("show", help="show a skill in the zone")
780
+ p_skill_show.add_argument("name")
781
+
782
+ p_save = sub.add_parser("save", help="persist stdin as zones/<conn>/scripts/<name>.py")
783
+ p_save.add_argument("name")
784
+ p_save.add_argument("-c", "--connection", help="DSN zone to save into")
785
+
786
+ p_run = sub.add_parser("run", help="execute a saved script in the helpers namespace")
787
+ p_run.add_argument("name")
788
+ p_run.add_argument("-c", "--connection", help="DSN zone to run from")
789
+
790
+ p_scripts = sub.add_parser("scripts", help="list saved scripts in a zone")
791
+ p_scripts.add_argument("-c", "--connection", help="DSN zone to list")
792
+
793
+ p_paths = sub.add_parser("paths", help="show every folder/file the CLI involves")
794
+ p_paths.add_argument("-c", "--connection", help="also show this DSN's zone dirs")
795
+
796
+ p_open = sub.add_parser("open", help="open a folder in your file manager")
797
+ p_open.add_argument(
798
+ "target", nargs="?", default="workspace", choices=_OPEN_TARGETS,
799
+ help="which folder (default: workspace)",
800
+ )
801
+ p_open.add_argument("-c", "--connection", help="DSN zone (for scripts/zone targets)")
802
+
803
+ sub.add_parser("init", help="write a starter connections.toml")
804
+ sub.add_parser("version", help="print versions")
805
+ sub.add_parser("doctor", help="test every configured connection")
806
+
807
+ # --- ssh subcommand group -----------------------------------------------
808
+ p_ssh = sub.add_parser("ssh", help="run shell commands / file ops on an SSH workspace")
809
+ p_ssh.add_argument("-c", "--connection", help="which SSH connection to use")
810
+ ssh_sub = p_ssh.add_subparsers(dest="ssh_cmd")
811
+
812
+ p_ssh_exec = ssh_sub.add_parser("exec", help="run a shell command and print stdout/stderr")
813
+ p_ssh_exec.add_argument("command", help="shell command (quoted)")
814
+ p_ssh_exec.add_argument("--timeout", type=float, default=30.0, help="seconds")
815
+
816
+ p_ssh_up = ssh_sub.add_parser("upload", help="upload a local file to the remote")
817
+ p_ssh_up.add_argument("local", help="local file path")
818
+ p_ssh_up.add_argument("remote", help="remote file path")
819
+
820
+ p_ssh_dl = ssh_sub.add_parser("download", help="download a remote file to local")
821
+ p_ssh_dl.add_argument("remote", help="remote file path")
822
+ p_ssh_dl.add_argument("local", help="local file path")
823
+
824
+ p_ssh_rs = ssh_sub.add_parser(
825
+ "run-script",
826
+ help="upload a local script to a remote dir and execute it",
827
+ )
828
+ p_ssh_rs.add_argument("local", help="local script path")
829
+ p_ssh_rs.add_argument("--remote-dir", default="/tmp", help="where to upload (default /tmp)")
830
+ p_ssh_rs.add_argument("--interpreter", default="bash", help="interpreter to invoke (default bash)")
831
+
832
+ p_ssh_info = ssh_sub.add_parser("info", help="show active SSH connection info")
833
+ return p
834
+
835
+
836
+ def main(argv: list[str]) -> int:
837
+ parser = _build_parser()
838
+ args = parser.parse_args(argv)
839
+ if not args.cmd:
840
+ parser.print_help()
841
+ return 0
842
+
843
+ cfg = load_config(config_file())
844
+ harness = SqlHarness(cfg)
845
+
846
+ commands: dict[str, Callable[[argparse.Namespace, SqlHarness], int]] = {
847
+ "list": cmd_list,
848
+ "add": cmd_add,
849
+ "edit": cmd_edit,
850
+ "remove": cmd_remove,
851
+ "show": cmd_show,
852
+ "test": cmd_test,
853
+ "workspace": cmd_workspace,
854
+ "skill": cmd_skill,
855
+ "save": cmd_save,
856
+ "run": cmd_run,
857
+ "scripts": cmd_scripts,
858
+ "paths": cmd_paths,
859
+ "open": cmd_open,
860
+ "ssh": cmd_ssh,
861
+ "init": cmd_init,
862
+ "version": cmd_version,
863
+ "doctor": cmd_doctor,
864
+ }
865
+ handler = commands[args.cmd]
866
+ try:
867
+ return handler(args, harness)
868
+ finally:
869
+ harness.close_all()