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/SKILL.md +61 -0
- sql_harness/__init__.py +27 -0
- sql_harness/agent_loader.py +55 -0
- sql_harness/cli.py +869 -0
- sql_harness/config.py +241 -0
- sql_harness/drivers/__init__.py +59 -0
- sql_harness/drivers/mysql.py +43 -0
- sql_harness/drivers/postgres.py +55 -0
- sql_harness/drivers/redis.py +33 -0
- sql_harness/drivers/sqlite.py +39 -0
- sql_harness/drivers/ssh.py +293 -0
- sql_harness/helpers.py +789 -0
- sql_harness/manager.py +122 -0
- sql_harness/paths.py +112 -0
- sql_harness/run.py +85 -0
- sql_harness-0.1.0.dist-info/METADATA +137 -0
- sql_harness-0.1.0.dist-info/RECORD +19 -0
- sql_harness-0.1.0.dist-info/WHEEL +4 -0
- sql_harness-0.1.0.dist-info/entry_points.txt +2 -0
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()
|