sqlbuddy-nwi 0.3.1__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.
cli.py ADDED
@@ -0,0 +1,1164 @@
1
+ """sql-buddy command-line interface.
2
+
3
+ Single dispatcher behind the `sql-buddy` console script AND the subcommand
4
+ entry for `python server.py <cmd>`.
5
+
6
+ Subcommands:
7
+ (none) | serve | stdio run the MCP server over stdio (normal mode)
8
+ version | --version | -V print version
9
+ doctor [--connect] validate install + db.ini (+ live pings if --connect)
10
+ update-check compare local checkout to the git remote
11
+ mcp print [--python] print the MCP client config block (paths filled in)
12
+ mcp add [--client claude|trae] [--scope project|user] [--python]
13
+ write the config block into a client's config file
14
+ init run the interactive setup wizard
15
+ help | --help | -h show usage
16
+
17
+ Bare `sql-buddy` (or `python server.py` with no subcommand) starts the MCP
18
+ server, so existing MCP client configs keep working unchanged.
19
+ """
20
+
21
+ import json
22
+ import os
23
+ import shutil
24
+ import stat
25
+ import subprocess
26
+ import sys
27
+ import tempfile
28
+
29
+ _HERE = os.path.dirname(os.path.abspath(__file__))
30
+
31
+ # The Python version floor declared in pyproject.toml (requires-python).
32
+ _MIN_PY = (3, 11)
33
+
34
+ # PyPI distribution name. Differs from the command (`sql-buddy`) and the MCP
35
+ # server name because `sql-buddy` was already taken on PyPI by an unrelated
36
+ # 2020 project — see docs/adr/0004-published-as-sqlbuddy-nwi.md. uvx therefore
37
+ # needs `--from sqlbuddy-nwi sql-buddy` (package name != command name).
38
+ _DIST_NAME = "sqlbuddy-nwi"
39
+
40
+ # Runtime dependencies doctor verifies are importable. These mirror
41
+ # pyproject.toml [project.dependencies] (plus the system `ssh` binary for
42
+ # Auto-mode tunnels, checked separately).
43
+ _RUNTIME_DEPS = [
44
+ ("mcp", "mcp.server.fastmcp"),
45
+ ("pymysql", None),
46
+ ("clickhouse_driver", None),
47
+ ("pandas", None),
48
+ ("platformdirs", None),
49
+ ]
50
+
51
+
52
+ # ---------------------------------------------------------------------------
53
+ # version
54
+ # ---------------------------------------------------------------------------
55
+
56
+ def get_version() -> str:
57
+ """Installed dist version if available, else the bundled literal."""
58
+ try:
59
+ from importlib.metadata import version, PackageNotFoundError
60
+ try:
61
+ return version(_DIST_NAME)
62
+ except PackageNotFoundError:
63
+ pass
64
+ except Exception:
65
+ pass
66
+ try:
67
+ from _version import __version__
68
+ return __version__
69
+ except Exception:
70
+ return "0.0.0+unknown"
71
+
72
+
73
+ def cmd_version() -> int:
74
+ print(get_version())
75
+ return 0
76
+
77
+
78
+ # ---------------------------------------------------------------------------
79
+ # doctor
80
+ # ---------------------------------------------------------------------------
81
+
82
+ def _check_python() -> tuple[str, bool]:
83
+ ok = sys.version_info >= _MIN_PY
84
+ return (f"Python {sys.version_info.major}.{sys.version_info.minor}"
85
+ f".{sys.version_info.micro} (need >={_MIN_PY[0]}.{_MIN_PY[1]})", ok)
86
+
87
+
88
+ def _check_dep(dist: str, mod: str | None) -> tuple[str, bool]:
89
+ import importlib
90
+ target = mod or dist
91
+ try:
92
+ importlib.import_module(target)
93
+ return f"dependency importable: {target}", True
94
+ except Exception as e:
95
+ return f"dependency MISSING: {target} ({e})", False
96
+
97
+
98
+ def _has_tunnel(cfg) -> bool:
99
+ """True if any connection declares a self-managed SSH tunnel."""
100
+ if cfg is None:
101
+ return False
102
+ for name in _connection_names(cfg):
103
+ val = cfg.get(name, "tunnel", fallback="").strip()
104
+ if val:
105
+ return True
106
+ return False
107
+
108
+
109
+ def _check_ssh(needed: bool) -> tuple[str, str, str]:
110
+ """Return (label, severity, detail). severity in {'ok','warn','fail'}.
111
+
112
+ `needed` says whether ANY configured connection actually uses a self-managed
113
+ tunnel. ssh is only required in that case, so a missing binary is a hard
114
+ failure only when a tunnel is configured; otherwise it's a non-blocking warn
115
+ (e.g. an External/direct-only setup never needs ssh).
116
+ """
117
+ path = shutil.which("ssh")
118
+ if path:
119
+ return "ssh binary", "ok", f"on PATH: {path}"
120
+ if needed:
121
+ hint = (
122
+ "install OpenSSH Client (Windows Settings → Apps → Optional features)"
123
+ if sys.platform == "win32"
124
+ else "install an OpenSSH client (e.g. openssh-client)"
125
+ )
126
+ return ("ssh binary", "fail",
127
+ "NOT on PATH but a connection uses a self-managed tunnel "
128
+ f"(Auto mode); {hint}")
129
+ return ("ssh binary", "warn",
130
+ "not on PATH (only needed for Auto-mode tunnels — fine for "
131
+ "External/direct-only setups)")
132
+
133
+
134
+ def _parse_db_ini():
135
+ """Return (configparser, error_or_None).
136
+
137
+ Uses the shared `connections.config_util.read_config` reader (UTF-8-BOM /
138
+ UTF-8 / GBK, same as the runtime) so doctor agrees with what the server
139
+ will actually load. Never echoes the raw parse exception — ConfigParser
140
+ errors embed the offending source line, which on db.ini can be a password.
141
+ """
142
+ from connections.config_util import config_path, read_config
143
+ cfg_file = config_path()
144
+ if not os.path.exists(cfg_file):
145
+ return None, f"db.ini not found at {cfg_file} (run: sql-buddy init)"
146
+ try:
147
+ return read_config(cfg_file), None
148
+ except Exception:
149
+ return None, ("db.ini failed to parse (syntax/encoding error; the "
150
+ "offending line is hidden to avoid leaking secrets)")
151
+
152
+
153
+ def _is_ssh_section(name: str) -> bool:
154
+ """Match runtime/tunnel.py: section names are matched case-insensitively."""
155
+ return name.lower().startswith("ssh:")
156
+
157
+
158
+ def _connection_names(cfg) -> list[str]:
159
+ return [s for s in cfg.sections() if not _is_ssh_section(s)]
160
+
161
+
162
+ def _ssh_block_keys(cfg) -> set[str]:
163
+ """Lowercased bastion names from every [ssh:*]/[SSH:*] section."""
164
+ keys = set()
165
+ for s in cfg.sections():
166
+ if _is_ssh_section(s):
167
+ keys.add(s.split(":", 1)[1].lower())
168
+ return keys
169
+
170
+
171
+ def _tunnel_block_name(raw: str) -> str:
172
+ """Extract bastion name from tunnel=ssh:prod / tunnel=SSH:prod / tunnel=prod."""
173
+ val = (raw or "").strip()
174
+ if val.lower().startswith("ssh:"):
175
+ return val.split(":", 1)[1].lower()
176
+ return val.lower()
177
+
178
+
179
+ _VALID_TYPES = frozenset({"clickhouse", "mysql", "mariadb"})
180
+
181
+
182
+ def _port_ok(raw: str) -> bool:
183
+ """True if raw is an integer port in 1..65535 (runtime does int(port))."""
184
+ try:
185
+ p = int(str(raw).strip())
186
+ except (TypeError, ValueError):
187
+ return False
188
+ return 1 <= p <= 65535
189
+
190
+
191
+ def _resolve_type_like_runtime(cfg, name: str) -> tuple[str | None, str | None]:
192
+ """Mirror connections.manager.connection_type (without importing manager).
193
+
194
+ Returns (ctype, error). error is set when runtime would raise ValueError
195
+ (no explicit type and name prefix doesn't match ck*/db*/mysql*).
196
+ """
197
+ explicit = cfg.get(name, "type", fallback="").strip().lower()
198
+ if explicit:
199
+ if explicit not in _VALID_TYPES:
200
+ return None, f"unknown type '{explicit}' (want clickhouse|mysql|mariadb)"
201
+ return explicit, None
202
+ low = name.lower()
203
+ if low.startswith("ck"):
204
+ return "clickhouse", None
205
+ if low.startswith("db") or low.startswith("mysql"):
206
+ return "mysql", None
207
+ return None, "missing type (set type=clickhouse|mysql, or name the section ck*/db*/mysql*)"
208
+
209
+
210
+ def _check_config(cfg) -> list[tuple[str, bool, str]]:
211
+ """Static validation of db.ini shape. Returns (label, ok, detail) rows.
212
+
213
+ Mirrors runtime rules: connection_type() resolution + int(port) conversion.
214
+ SSH section/tunnel prefixes are matched case-insensitively (like tunnel.py).
215
+ """
216
+ rows: list[tuple[str, bool, str]] = []
217
+ conns = _connection_names(cfg)
218
+ if not conns:
219
+ rows.append(("db.ini: no connections defined", False,
220
+ "add at least one [section] (see db.ini.example)"))
221
+ ssh_sections = [s for s in cfg.sections() if _is_ssh_section(s)]
222
+ known_blocks = _ssh_block_keys(cfg)
223
+ for name in conns:
224
+ tunneled = cfg.has_option(name, "tunnel") and cfg.get(name, "tunnel", fallback="").strip()
225
+ issues = []
226
+ ctype, type_err = _resolve_type_like_runtime(cfg, name)
227
+ if type_err:
228
+ issues.append(type_err)
229
+ if tunneled:
230
+ block = _tunnel_block_name(tunneled)
231
+ if block not in known_blocks:
232
+ issues.append(f"tunnel references missing [ssh:{block}]")
233
+ for req in ("remote_host", "remote_port", "local_port"):
234
+ if not cfg.has_option(name, req):
235
+ issues.append(f"missing {req}")
236
+ for pkey in ("remote_port", "local_port"):
237
+ if cfg.has_option(name, pkey) and not _port_ok(cfg.get(name, pkey)):
238
+ issues.append(f"{pkey} must be integer 1..65535")
239
+ else:
240
+ for req in ("host", "port"):
241
+ if not cfg.has_option(name, req):
242
+ issues.append(f"missing {req}")
243
+ if cfg.has_option(name, "port") and not _port_ok(cfg.get(name, "port")):
244
+ issues.append("port must be integer 1..65535")
245
+ if not cfg.has_option(name, "user"):
246
+ issues.append("missing user")
247
+ detail = ", ".join(issues) if issues else (
248
+ f"{ctype or '?'}" + (f", tunnel={tunneled}" if tunneled else ", direct")
249
+ )
250
+ rows.append((f"connection [{name}]", not issues, detail))
251
+ for block in ssh_sections:
252
+ for req in ("host", "user", "key"):
253
+ if not cfg.has_option(block, req):
254
+ rows.append((f"bastion [{block}]", False, f"missing {req}"))
255
+ if cfg.has_option(block, "port") and not _port_ok(cfg.get(block, "port")):
256
+ rows.append((f"bastion [{block}]", False, "port must be integer 1..65535"))
257
+ return rows
258
+
259
+
260
+ def _trae_user_mcp_candidates() -> list[str]:
261
+ """Likely Trae user-scope mcp.json locations (first existing wins for write).
262
+
263
+ Project-scope is always `<cwd>/.trae/mcp.json` (official Trae docs).
264
+ User-scope is not fully documented; Trae is VS Code-based so we probe the
265
+ standard User/ folder plus a ~/.trae fallback used by some installs.
266
+ """
267
+ home = os.path.expanduser("~")
268
+ names = ("Trae", "Trae CN", "Trae-CN", "trae")
269
+ out: list[str] = []
270
+ if sys.platform == "win32":
271
+ appdata = os.environ.get("APPDATA") or os.path.join(
272
+ home, "AppData", "Roaming")
273
+ for name in names:
274
+ out.append(os.path.join(appdata, name, "User", "mcp.json"))
275
+ elif sys.platform == "darwin":
276
+ base = os.path.join(home, "Library", "Application Support")
277
+ for name in names:
278
+ out.append(os.path.join(base, name, "User", "mcp.json"))
279
+ else:
280
+ for name in names:
281
+ out.append(os.path.join(home, ".config", name, "User", "mcp.json"))
282
+ out.append(os.path.join(home, ".trae", "mcp.json"))
283
+ seen: set[str] = set()
284
+ uniq: list[str] = []
285
+ for p in out:
286
+ key = os.path.normcase(os.path.normpath(p))
287
+ if key not in seen:
288
+ seen.add(key)
289
+ uniq.append(p)
290
+ return uniq
291
+
292
+
293
+ def _mcp_candidate_files() -> list[str]:
294
+ home = os.path.expanduser("~")
295
+ cands = [
296
+ os.path.join(os.getcwd(), ".mcp.json"),
297
+ os.path.join(os.getcwd(), ".trae", "mcp.json"),
298
+ os.path.join(os.getcwd(), ".claude", "settings.json"),
299
+ os.path.join(home, ".claude.json"),
300
+ ]
301
+ cands.extend(_trae_user_mcp_candidates())
302
+ if sys.platform == "darwin":
303
+ cands.append(os.path.join(home, "Library", "Application Support",
304
+ "Claude", "claude_desktop_config.json"))
305
+ if sys.platform == "win32":
306
+ appdata = os.environ.get("APPDATA") or os.environ.get("AppData")
307
+ if appdata:
308
+ cands.append(os.path.join(appdata, "Claude",
309
+ "claude_desktop_config.json"))
310
+ seen: set[str] = set()
311
+ uniq: list[str] = []
312
+ for p in cands:
313
+ key = os.path.normcase(os.path.normpath(p))
314
+ if key not in seen:
315
+ seen.add(key)
316
+ uniq.append(p)
317
+ return uniq
318
+
319
+
320
+ def _is_trae_config_path(path: str) -> bool:
321
+ """True if `path` is a Trae MCP config (project .trae/mcp.json or User/mcp.json)."""
322
+ norm = path.replace("\\", "/").lower()
323
+ if norm.endswith("/.trae/mcp.json") or norm.endswith("/.trae\\mcp.json"):
324
+ return True
325
+ if "/.trae/" in norm and norm.endswith("mcp.json"):
326
+ return True
327
+ # User-scope: …/Trae/User/mcp.json or …/Trae CN/User/mcp.json
328
+ if norm.endswith("/user/mcp.json") and "/trae" in norm:
329
+ return True
330
+ if norm.endswith("/.trae/mcp.json"):
331
+ return True
332
+ return False
333
+
334
+
335
+ def _validate_mcp_entry(entry: dict, *, trae: bool = False) -> tuple[bool, str]:
336
+ """Check one mcpServers entry resolves: command on PATH/exists AND every
337
+ absolute path in its args is present. Returns (ok, detail).
338
+
339
+ Every non-absolute command is checked with shutil.which (not an allowlist) —
340
+ a bare name like `sql-buddy` or a typo must fail, not silently "resolve".
341
+ Absolute / relative path commands are checked on the filesystem.
342
+
343
+ When `trae=True`, a command field containing spaces is a hard failure —
344
+ Trae's host rejects those configs (see docs.trae.ai add-mcp-servers).
345
+ """
346
+ if not isinstance(entry, dict):
347
+ return False, f"entry must be an object, got {type(entry).__name__}"
348
+ cmd = entry.get("command")
349
+ args = entry.get("args", [])
350
+ if args is None:
351
+ args = []
352
+ if not isinstance(args, list):
353
+ return False, f"'args' must be a list, got {type(args).__name__}"
354
+ if not cmd or not isinstance(cmd, str):
355
+ return False, "no 'command' set"
356
+ # Trae: command must not contain spaces (host parse rule).
357
+ if trae and " " in cmd:
358
+ return False, (
359
+ f"command path contains spaces (Trae rejects this): {cmd} — "
360
+ "re-run: sql-buddy mcp add --client trae after installing uv/python "
361
+ "on a path without spaces"
362
+ )
363
+ if os.path.isabs(cmd) or os.sep in cmd or (os.altsep and os.altsep in cmd):
364
+ # Absolute or relative filesystem path — must be a regular file.
365
+ # On Windows, X_OK is not meaningful for .exe resolution (PATHEXT).
366
+ if not os.path.isfile(cmd):
367
+ return False, f"command path MISSING or not a file: {cmd}"
368
+ # Unix: require +x. Windows: X_OK is not meaningful (PATHEXT); skip.
369
+ if sys.platform != "win32" and not os.access(cmd, os.X_OK):
370
+ return False, f"command path is not executable: {cmd}"
371
+ else:
372
+ # Bare executable name — must be on PATH
373
+ if not shutil.which(cmd):
374
+ return False, f"command '{cmd}' not found on PATH"
375
+ missing = [a for a in args
376
+ if isinstance(a, str) and os.path.isabs(a) and not os.path.exists(a)]
377
+ if missing:
378
+ return False, f"arg path(s) MISSING: {', '.join(missing)}"
379
+ return True, f"command '{cmd}' resolves"
380
+
381
+
382
+ def _check_mcp_config() -> list[tuple[str, bool, str]]:
383
+ rows: list[tuple[str, bool, str]] = []
384
+ found_any = False
385
+ for path in _mcp_candidate_files():
386
+ if not os.path.exists(path):
387
+ continue
388
+ try:
389
+ # utf-8-sig: Trae / Windows editors may write a BOM.
390
+ data = _read_json_file(path)
391
+ except Exception as e:
392
+ rows.append((f"MCP config {path}", False, f"unreadable: {e}"))
393
+ continue
394
+ if not isinstance(data, dict):
395
+ rows.append((f"MCP config {path}", False, "top-level JSON is not an object"))
396
+ continue
397
+ servers = data.get("mcpServers", {})
398
+ if servers is None:
399
+ servers = {}
400
+ if not isinstance(servers, dict):
401
+ rows.append((f"MCP config {path}", False,
402
+ f"'mcpServers' must be an object, got {type(servers).__name__}"))
403
+ continue
404
+ trae = _is_trae_config_path(path)
405
+ if "sql-buddy" in servers:
406
+ found_any = True
407
+ ent = servers["sql-buddy"]
408
+ if not isinstance(ent, dict):
409
+ rows.append((f"MCP config {path} [sql-buddy]", False,
410
+ f"entry must be an object, got {type(ent).__name__}"))
411
+ else:
412
+ ok, detail = _validate_mcp_entry(ent, trae=trae)
413
+ rows.append((f"MCP config {path} [sql-buddy]", ok, detail))
414
+ else:
415
+ # Only treat an entry as sql-buddy if it is SQL-Buddy-specific —
416
+ # bare "server.py" is too generic (any Python MCP server).
417
+ for name, ent in servers.items():
418
+ if not isinstance(ent, dict):
419
+ continue
420
+ if not _looks_like_sql_buddy_entry(name, ent):
421
+ continue
422
+ found_any = True
423
+ ok, detail = _validate_mcp_entry(ent, trae=trae)
424
+ rows.append((f"MCP config {path} [{name}]", ok, detail))
425
+ if not found_any:
426
+ rows.append(("MCP client config", False,
427
+ "sql-buddy not registered with any detected client "
428
+ "(run: sql-buddy mcp add)"))
429
+ return rows
430
+
431
+
432
+ def _looks_like_sql_buddy_entry(name: str, ent: dict) -> bool:
433
+ """True only for entries that clearly point at this project."""
434
+ if name.lower() in ("sql-buddy", "sql_buddy", "sqlbuddy"):
435
+ return True
436
+ blob = json.dumps(ent).lower()
437
+ if "sql-buddy" in blob or "sql_buddy" in blob:
438
+ return True
439
+ # resolved path into *this* checkout's server.py
440
+ here = _HERE.lower()
441
+ args = ent.get("args") or []
442
+ if isinstance(args, list):
443
+ for a in args:
444
+ if isinstance(a, str) and here in a.lower() and a.lower().endswith("server.py"):
445
+ return True
446
+ cmd = ent.get("command")
447
+ if isinstance(cmd, str) and here in cmd.lower() and "sql-buddy" in cmd.lower():
448
+ return True
449
+ return False
450
+
451
+
452
+ def _sanitize_detail(text: str) -> str:
453
+ """Redact known secrets from doctor detail lines (best-effort)."""
454
+ if not text:
455
+ return text
456
+ try:
457
+ import db_security
458
+ db_security.install()
459
+ return db_security.sanitize_text(text)
460
+ except Exception:
461
+ return _redact_secrets(text)
462
+
463
+
464
+ def _live_hint(msg: str) -> str:
465
+ """Actionable hint for a live-Connection failure. Mirrors server._hint_for
466
+ without coupling doctor to the MCP runtime. Always sanitized."""
467
+ msg = _sanitize_detail(msg)
468
+ low = msg.lower()
469
+ if any(k in low for k in ("refused", "timed out", "timeout", "tunnel",
470
+ "connect", "socket", " eof", "closed")):
471
+ return (f"{msg} | hint: transport/tunnel error — in External mode the "
472
+ "user's tunnel may be down; in Auto mode check remote_host/"
473
+ "remote_port (the DB as seen from the bastion)")
474
+ if any(k in low for k in ("access denied", "authentication", "auth",
475
+ "password", "login")):
476
+ return f"{msg} | hint: DB auth failed — check user/password in db.ini"
477
+ if "no section" in low or "unknown connection" in low:
478
+ return (f"{msg} | hint: unknown connection — run sql-buddy doctor to "
479
+ "list configured names")
480
+ return msg
481
+
482
+
483
+ def _check_connection_live(name: str) -> tuple[str, str, str]:
484
+ """Ping one Connection (SELECT 1). Returns (label, severity, detail)."""
485
+ try:
486
+ # Match server startup: load .env + install secret filters before any
487
+ # db_query / connections import so $env: refs in db.ini resolve and
488
+ # exception text is redacted.
489
+ import db_security
490
+ db_security.install()
491
+ import db_query
492
+ raw = db_query.check_connection_query(name)
493
+ res = json.loads(raw)
494
+ if res.get("status") == "ok":
495
+ return (f"connection [{name}] reachable", "ok",
496
+ f"{res.get('latency_ms')} ms")
497
+ err = _sanitize_detail(str(res.get("error") or res))
498
+ return (f"connection [{name}] error", "fail", err)
499
+ except Exception as e:
500
+ return (f"connection [{name}] error", "fail", _live_hint(str(e)))
501
+
502
+
503
+ def doctor(connect: bool = False) -> int:
504
+ """Validate the install. Returns process exit code (0 unless something FAILs;
505
+ WARNs never fail)."""
506
+ print(f"sql-buddy {get_version()} doctor\n")
507
+ fails = 0
508
+ warns = 0
509
+
510
+ def emit(label, severity, detail=""):
511
+ nonlocal fails, warns
512
+ if severity == "ok":
513
+ mark = "OK "
514
+ elif severity == "warn":
515
+ mark = "WARN"
516
+ warns += 1
517
+ else:
518
+ mark = "FAIL"
519
+ fails += 1
520
+ line = f" [{mark}] {label}"
521
+ if detail:
522
+ line += f" ({detail})"
523
+ print(line)
524
+
525
+ # Parse db.ini once, up front, so the ssh check can be conditional on
526
+ # whether any connection actually uses a self-managed tunnel.
527
+ cfg, err = _parse_db_ini()
528
+
529
+ # 1. runtime environment
530
+ print("== runtime ==")
531
+ lbl, ok = _check_python()
532
+ emit(lbl, "ok" if ok else "fail")
533
+ emit(*_check_ssh(needed=_has_tunnel(cfg)))
534
+ for dist, mod in _RUNTIME_DEPS:
535
+ d_label, d_ok = _check_dep(dist, mod)
536
+ emit(d_label, "ok" if d_ok else "fail")
537
+
538
+ # 2. config
539
+ print("\n== db.ini ==")
540
+ if err:
541
+ emit("db.ini", "fail", err)
542
+ cfg = None
543
+ else:
544
+ conns = _connection_names(cfg)
545
+ from connections.config_util import config_path as _config_path
546
+ emit(f"db.ini parsed ({len(conns)} connection(s))", "ok", _config_path())
547
+ for label, ok, detail in _check_config(cfg):
548
+ emit(label, "ok" if ok else "fail", detail)
549
+
550
+ # 3. mcp client config
551
+ print("\n== MCP client config ==")
552
+ for label, ok, detail in _check_mcp_config():
553
+ emit(label, "ok" if ok else "fail", detail)
554
+
555
+ # 4. live Connection pings (opt-in — may open tunnels / hit the network)
556
+ if connect and cfg is not None:
557
+ print("\n== live connections (SELECT 1) ==")
558
+ for name in _connection_names(cfg):
559
+ emit(*_check_connection_live(name))
560
+ elif connect and cfg is None:
561
+ print("\n== live connections skipped (no db.ini) ==")
562
+
563
+ summary = ", ".join([f"{fails} failed", f"{warns} warning(s)"])
564
+ print(f"\n{'All checks passed.' if fails == 0 and warns == 0 else summary}")
565
+ return 1 if fails else 0
566
+
567
+
568
+ # ---------------------------------------------------------------------------
569
+ # update-check
570
+ # ---------------------------------------------------------------------------
571
+
572
+ def _redact_secrets(text: str) -> str:
573
+ """Strip credential-bearing URL userinfo (and similar) from diagnostic text.
574
+
575
+ Git stderr often embeds the remote URL; if the remote is an HTTPS URL with
576
+ an embedded token (`https://user:TOKEN@host/...`), echoing raw stderr would
577
+ leak it. Replace userinfo with `***`.
578
+ """
579
+ if not text:
580
+ return text
581
+ import re
582
+ # https://user:pass@host or https://token@host
583
+ text = re.sub(r"(https?://)([^/@\s]+)@", r"\1***@", text)
584
+ # ssh-style with embedded password is rare; still strip user:pass@host
585
+ text = re.sub(r"(\b)([^:\s/]+):([^@\s/]+)@", r"\1***@", text)
586
+ return text
587
+
588
+
589
+ def _git(args: list[str], cwd: str = _HERE) -> tuple[int, str, str]:
590
+ try:
591
+ p = subprocess.run(["git", *args], cwd=cwd, capture_output=True, text=True)
592
+ return p.returncode, p.stdout.strip(), _redact_secrets(p.stderr.strip())
593
+ except FileNotFoundError:
594
+ return 127, "", "git not installed"
595
+
596
+
597
+ def update_check() -> int:
598
+ """Compare the local checkout HEAD to the upstream tracking branch tip."""
599
+ rc, local, _ = _git(["rev-parse", "HEAD"])
600
+ if rc != 0:
601
+ print("Not a git checkout (or git unavailable) — cannot check for updates.")
602
+ print("If you installed via a vendored bundle, re-download the latest "
603
+ "release to update.")
604
+ return 0
605
+ print(f"local : {local[:12]} sql-buddy {get_version()}")
606
+
607
+ remote_name, branch_name = "origin", "master"
608
+ rc, upstream_ref, _ = _git(["rev-parse", "--abbrev-ref", "--symbolic-full-name", "@{u}"])
609
+ if rc != 0:
610
+ # No upstream configured. Try origin/<current-branch> as a best effort.
611
+ rc2, branch, _ = _git(["rev-parse", "--abbrev-ref", "HEAD"])
612
+ branch_name = branch or "master"
613
+ remote_name = "origin"
614
+ upstream_label = f"origin/{branch_name}"
615
+ else:
616
+ upstream_label = upstream_ref
617
+ parts = upstream_ref.split("/", 1)
618
+ if len(parts) == 2:
619
+ remote_name, branch_name = parts
620
+ else:
621
+ remote_name, branch_name = "origin", parts[0]
622
+
623
+ # Never print raw git stderr: it can embed credential-bearing remote URLs
624
+ # (https://user:TOKEN@host/...) even after partial redaction.
625
+ rc, remote, _err = _git(["ls-remote", remote_name, branch_name])
626
+ if rc != 0:
627
+ print(f"Could not reach '{upstream_label}' (offline or auth failed). "
628
+ "Skipping remote comparison. Try: git fetch")
629
+ return 0
630
+ remote_sha = (remote.split()[0] if remote else "").strip()
631
+
632
+ print(f"remote: {remote_sha[:12] if remote_sha else '(none)'} {upstream_label}")
633
+ if not remote_sha:
634
+ print("Remote ref not found. Check your network or the branch name.")
635
+ return 0
636
+ if remote_sha == local:
637
+ print("Up to date.")
638
+ return 0
639
+
640
+ # ls-remote only returns a SHA — the object may not be in the local store.
641
+ # merge-base needs the object; fetch the tip if missing, otherwise ancestry
642
+ # checks exit 128 and we'd mis-report "diverged".
643
+ rc_has, _, _ = _git(["cat-file", "-e", f"{remote_sha}^{{commit}}"])
644
+ if rc_has != 0:
645
+ rc_f, _, _err_f = _git(["fetch", "--quiet", remote_name, branch_name])
646
+ if rc_f != 0:
647
+ print(f"Remote tip {remote_sha[:12]} is not in local history and "
648
+ f"fetch failed. Run: git fetch")
649
+ return 0
650
+ # re-check after fetch
651
+ rc_has2, _, _ = _git(["cat-file", "-e", f"{remote_sha}^{{commit}}"])
652
+ if rc_has2 != 0:
653
+ print(f"Fetched {upstream_label} but still missing object "
654
+ f"{remote_sha[:12]}. Run: git fetch {remote_name}")
655
+ return 0
656
+
657
+ # Distinguish behind / ahead / diverged via commit ancestry.
658
+ # `merge-base --is-ancestor A B` exits 0 when A is reachable from B.
659
+ rc_local_in_remote, _, _ = _git(
660
+ ["merge-base", "--is-ancestor", "HEAD", remote_sha])
661
+ rc_remote_in_local, _, _ = _git(
662
+ ["merge-base", "--is-ancestor", remote_sha, "HEAD"])
663
+ # exit 128 = git error (still-missing object, corrupt repo, …) — don't
664
+ # pretend we know the relationship.
665
+ if rc_local_in_remote == 128 or rc_remote_in_local == 128:
666
+ print("Could not compare ancestry. Run: git fetch && git status")
667
+ return 0
668
+ if rc_local_in_remote == 0:
669
+ print("Update available. Run: git pull")
670
+ print("(then `uv pip install -e .` if dependencies changed)")
671
+ elif rc_remote_in_local == 0:
672
+ print("Local is ahead of remote (unpushed commits). No update needed.")
673
+ else:
674
+ print("Branches have diverged. Run: git pull --rebase (or merge) and resolve.")
675
+ return 0
676
+
677
+
678
+ # ---------------------------------------------------------------------------
679
+ # mcp print / add
680
+ # ---------------------------------------------------------------------------
681
+
682
+ _SUPPORTED_CLIENTS = ("claude", "trae")
683
+
684
+
685
+ def _server_py() -> str:
686
+ return os.path.join(_HERE, "server.py")
687
+
688
+
689
+ def _user_cache_dir() -> str:
690
+ """Platform cache root for sql-buddy (backups, etc.)."""
691
+ if sys.platform == "win32":
692
+ base = os.environ.get("LOCALAPPDATA") or os.path.join(
693
+ os.path.expanduser("~"), "AppData", "Local")
694
+ return os.path.join(base, "sql-buddy")
695
+ if sys.platform == "darwin":
696
+ return os.path.join(os.path.expanduser("~"), "Library", "Caches",
697
+ "sql-buddy")
698
+ return os.path.join(os.path.expanduser("~"), ".cache", "sql-buddy")
699
+
700
+
701
+ def _norm_mcp_path(path: str) -> str:
702
+ """Absolute, normalized path for embedding in MCP JSON."""
703
+ return os.path.normpath(os.path.abspath(path))
704
+
705
+
706
+ def _resolve_exe(name: str) -> str | None:
707
+ """Absolute path to a PATH executable, or None."""
708
+ found = shutil.which(name)
709
+ if not found:
710
+ return None
711
+ return _norm_mcp_path(found)
712
+
713
+
714
+ def _mcp_env(client: str) -> dict[str, str]:
715
+ """Env block for MCP entries. Unbuffered stdio + Windows UTF-8 + Trae timeouts."""
716
+ # Unbuffered so JSON-RPC over stdio is never delayed by block buffering.
717
+ env: dict[str, str] = {"PYTHONUNBUFFERED": "1"}
718
+ if sys.platform == "win32":
719
+ # Chinese/legacy Windows code pages break JSON-RPC over stdio otherwise.
720
+ env["PYTHONUTF8"] = "1"
721
+ env["PYTHONIOENCODING"] = "utf-8"
722
+ if client == "trae":
723
+ # First `uv run` (venv provision) can exceed Trae's default start budget.
724
+ env["START_MCP_TIMEOUT_MS"] = "120000"
725
+ env["RUN_MCP_TIMEOUT_MS"] = "120000"
726
+ return env
727
+
728
+
729
+ def _command_has_spaces(cmd: str) -> bool:
730
+ return " " in (cmd or "")
731
+
732
+
733
+ def _mcp_entry(python_mode: bool, *, project_relative: bool = False,
734
+ client: str = "claude", command_override: str = None) -> dict:
735
+ """Build the mcpServers['sql-buddy'] entry, paths auto-filled.
736
+
737
+ Mode selection (see docs/adr/0002-config-discovery-and-invocation-mode-on-publish.md):
738
+ --command <cmd> explicit override (shlex-split into command + args)
739
+ --python the running interpreter + server.py
740
+ auto, checkout: `uv --directory <repo> run server.py` (auto-provisions
741
+ the venv; no absolute .venv path to go stale)
742
+ auto, installed: `uvx --from sqlbuddy-nwi sql-buddy` (PyPI; dist name
743
+ differs from the command), or `uv tool run --from
744
+ sqlbuddy-nwi sql-buddy`, or the `sql-buddy` console
745
+ script for a plain pip/pipx copy
746
+
747
+ `project_relative=True` writes `--directory .` so a checked-in `.mcp.json`
748
+ in this repo stays portable and is not dirtied with a machine-specific path.
749
+
750
+ On Windows / Trae we resolve bare names to absolute paths (GUI hosts often
751
+ lack the user's shell PATH). We never substitute `py -3` for sys.executable:
752
+ that would drop the active venv and its deps. If Trae cannot accept the
753
+ command path (spaces), callers must refuse rather than guess another Python.
754
+ """
755
+ env = _mcp_env(client)
756
+
757
+ if command_override:
758
+ import shlex
759
+ parts = shlex.split(command_override)
760
+ if not parts:
761
+ raise SystemExit("--command requires a non-empty command string")
762
+ entry: dict = {"command": parts[0], "args": parts[1:]}
763
+ elif python_mode:
764
+ # Always the invoking interpreter — keeps the active uv/.venv deps.
765
+ entry = {"command": _norm_mcp_path(sys.executable),
766
+ "args": [_norm_mcp_path(_server_py())]}
767
+ else:
768
+ from connections.config_util import is_checkout
769
+ if is_checkout():
770
+ # Absolute uv only where GUI hosts need it. Bare `uv` keeps Claude's
771
+ # checked-in .mcp.json portable on Unix.
772
+ if client == "trae" or sys.platform == "win32":
773
+ uv = _resolve_exe("uv") or "uv"
774
+ else:
775
+ uv = "uv"
776
+ directory = "." if project_relative else _norm_mcp_path(_HERE)
777
+ entry = {"command": uv,
778
+ "args": ["--directory", directory, "run", "server.py"]}
779
+ else:
780
+ entry = _installed_entry(client)
781
+
782
+ if env:
783
+ entry["env"] = env
784
+ return entry
785
+
786
+
787
+ def _installed_entry(client: str) -> dict:
788
+ """MCP entry for an installed (non-checkout) sql-buddy.
789
+
790
+ Prefer `uvx --from sqlbuddy-nwi sql-buddy` (the dist name differs from the
791
+ command name, so uvx needs --from; pulls the latest from PyPI into an
792
+ ephemeral venv); fall back to `uv tool run --from sqlbuddy-nwi sql-buddy` if
793
+ only `uv` is on PATH, then to the `sql-buddy` console script for a plain
794
+ pip/pipx install. GUI hosts (Trae) and Windows resolve bare names to
795
+ absolute paths so the host finds them without the user's shell PATH; Unix
796
+ non-Trae keeps them bare for portability.
797
+ """
798
+ gui = client == "trae" or sys.platform == "win32"
799
+
800
+ def pick(name: str) -> str:
801
+ return (_resolve_exe(name) or name) if gui else name
802
+
803
+ if shutil.which("uvx"):
804
+ return {"command": pick("uvx"),
805
+ "args": ["--from", _DIST_NAME, "sql-buddy"]}
806
+ if shutil.which("uv"):
807
+ return {"command": pick("uv"),
808
+ "args": ["tool", "run", "--from", _DIST_NAME, "sql-buddy"]}
809
+ return {"command": pick("sql-buddy"), "args": []}
810
+
811
+
812
+ def _mcp_mode_note(entry: dict, python_mode: bool, command_override: str | None,
813
+ rel: bool, checkout: bool) -> str:
814
+ """Human label for the mode _mcp_entry selected (for add/print messages)."""
815
+ if command_override:
816
+ return f"command override ({command_override})"
817
+ if python_mode:
818
+ return "python (sys.executable)"
819
+ if checkout:
820
+ return "uv relative (.)" if rel else "uv (auto-provisions venv)"
821
+ args = entry.get("args", [])
822
+ if args == ["--from", _DIST_NAME, "sql-buddy"]:
823
+ return f"uvx --from {_DIST_NAME} sql-buddy (PyPI)"
824
+ if len(args) >= 4 and args[:4] == ["tool", "run", "--from", _DIST_NAME]:
825
+ return f"uv tool run --from {_DIST_NAME} sql-buddy (PyPI)"
826
+ return "sql-buddy (console script)"
827
+
828
+
829
+ def _trae_command_space_error(cmd: str) -> str:
830
+ return (
831
+ f"refusing Trae config: command path contains spaces ({cmd!r}). "
832
+ "Trae's MCP host rejects a `command` field with spaces, and "
833
+ "substituting another Python (e.g. `py -3`) would drop this venv's "
834
+ "deps. Install uv (or move this checkout/venv) onto a path without "
835
+ "spaces, then re-run."
836
+ )
837
+
838
+
839
+ def cmd_mcp_print(python_mode: bool, client: str = "claude",
840
+ command_override: str = None) -> int:
841
+ # Relative --directory only makes sense inside a checkout (Claude project),
842
+ # matching the committed .mcp.json template. Trae project files live under
843
+ # .trae/ and use absolute --directory.
844
+ from connections.config_util import is_checkout
845
+ checkout = is_checkout()
846
+ rel = (client == "claude" and checkout
847
+ and os.path.realpath(os.getcwd()) == os.path.realpath(_HERE))
848
+ # Only a checkout needs uv to auto-provision the venv; installed copies pick
849
+ # uvx/console-script inside _mcp_entry.
850
+ if (checkout and not python_mode and not command_override
851
+ and not shutil.which("uv")):
852
+ print("# note: 'uv' not found on PATH; this config requires it. "
853
+ "Use --python for a sys.executable-based config.\n",
854
+ file=sys.stderr)
855
+ if client == "trae" or sys.platform == "win32":
856
+ python_mode = True
857
+ entry = _mcp_entry(python_mode, project_relative=rel and not python_mode,
858
+ client=client, command_override=command_override)
859
+ if client == "trae" and _command_has_spaces(entry.get("command", "")):
860
+ print(f"# warning: {_trae_command_space_error(entry.get('command', ''))}\n",
861
+ file=sys.stderr)
862
+ print(json.dumps({"mcpServers": {"sql-buddy": entry}}, indent=2))
863
+ return 0
864
+
865
+
866
+ def _read_json_file(path: str) -> dict:
867
+ # utf-8-sig: Windows editors (and Trae) may write a BOM.
868
+ with open(path, encoding="utf-8-sig") as f:
869
+ return json.load(f)
870
+
871
+
872
+ def _write_json_file(path: str, data: dict) -> None:
873
+ """Write JSON atomically, preserving the existing file mode.
874
+
875
+ MCP client configs (notably ~/.claude.json) are commonly mode 0600 and may
876
+ hold third-party API tokens. Writing through a naive temp file would reset
877
+ the mode to the process umask (often 0644), exposing them to other local
878
+ users. So: inherit the existing mode if the file exists, else create 0600;
879
+ and use a 0600 temp file so the data is never world-readable mid-write.
880
+ """
881
+ d = os.path.dirname(path) or "."
882
+ os.makedirs(d, exist_ok=True)
883
+ mode = 0o600
884
+ if os.path.exists(path):
885
+ mode = stat.S_IMODE(os.stat(path).st_mode)
886
+ fd, tmp = tempfile.mkstemp(dir=d, prefix=".sql-buddy-mcp-")
887
+ try:
888
+ try:
889
+ os.chmod(tmp, mode)
890
+ except OSError:
891
+ pass # Windows: chmod is best-effort
892
+ with os.fdopen(fd, "w", encoding="utf-8", newline="\n") as f:
893
+ json.dump(data, f, indent=2)
894
+ f.write("\n")
895
+ os.replace(tmp, path)
896
+ finally:
897
+ if os.path.exists(tmp):
898
+ os.remove(tmp)
899
+
900
+
901
+ def _client_config_path(client: str, scope: str) -> str:
902
+ client = (client or "claude").lower()
903
+ scope = (scope or "project").lower()
904
+ if client not in _SUPPORTED_CLIENTS:
905
+ raise SystemExit(
906
+ f"unsupported client '{client}' "
907
+ f"(want {', '.join(_SUPPORTED_CLIENTS)})"
908
+ )
909
+ home = os.path.expanduser("~")
910
+ if client == "claude":
911
+ if scope == "project":
912
+ return os.path.join(os.getcwd(), ".mcp.json")
913
+ if scope == "user":
914
+ return os.path.join(home, ".claude.json")
915
+ if client == "trae":
916
+ # Official: project-level MCP lives at <workspace>/.trae/mcp.json
917
+ # (enable "project-level MCP" in Trae Settings → MCP).
918
+ if scope == "project":
919
+ return os.path.join(os.getcwd(), ".trae", "mcp.json")
920
+ if scope == "user":
921
+ for cand in _trae_user_mcp_candidates():
922
+ if os.path.isfile(cand):
923
+ return cand
924
+ # Default create target (first platform candidate).
925
+ return _trae_user_mcp_candidates()[0]
926
+ raise SystemExit(f"unsupported scope '{scope}' (use 'project' or 'user')")
927
+
928
+
929
+ def _mcp_backup_path(path: str) -> str:
930
+ """Backup path OUTSIDE any git checkout (token-bearing client configs).
931
+
932
+ Project-scope `mcp add` may run inside an unrelated repository whose
933
+ .gitignore does not cover `.mcp.json.bak*`. Putting backups under the
934
+ user cache dir keeps them out of every working tree.
935
+ """
936
+ import hashlib
937
+ cache = os.path.join(_user_cache_dir(), "mcp-backups")
938
+ os.makedirs(cache, mode=0o700, exist_ok=True)
939
+ try:
940
+ os.chmod(cache, 0o700)
941
+ except OSError:
942
+ pass
943
+ digest = hashlib.sha256(os.path.abspath(path).encode()).hexdigest()[:12]
944
+ base = os.path.basename(path)
945
+ n = 1
946
+ while True:
947
+ cand = os.path.join(cache, f"{base}.{digest}.bak{n}")
948
+ if not os.path.exists(cand):
949
+ return cand
950
+ n += 1
951
+
952
+
953
+ def cmd_mcp_add(python_mode: bool, client: str, scope: str,
954
+ command_override: str = None) -> int:
955
+ path = _client_config_path(client, scope)
956
+ from connections.config_util import is_checkout
957
+ checkout = is_checkout()
958
+ # A checkout with no `uv` can't auto-provision its venv — fall back to the
959
+ # running interpreter so we never write a config the client can't launch.
960
+ # Installed copies select uvx/console-script inside _mcp_entry instead, so
961
+ # this fallback is checkout-only.
962
+ if (checkout and not python_mode and not command_override
963
+ and not shutil.which("uv")):
964
+ print("uv not on PATH; falling back to sys.executable "
965
+ "(pass --python to silence this).", file=sys.stderr)
966
+ python_mode = True
967
+ # Keep the committed .mcp.json portable when adding from inside this checkout.
968
+ # Trae project files are per-workspace (.trae/mcp.json) — always absolute.
969
+ rel = (client == "claude"
970
+ and scope == "project"
971
+ and checkout
972
+ and os.path.realpath(os.getcwd()) == os.path.realpath(_HERE)
973
+ and not python_mode
974
+ and not command_override)
975
+ entry = _mcp_entry(python_mode, project_relative=rel, client=client,
976
+ command_override=command_override)
977
+ if client == "trae" and _command_has_spaces(entry.get("command", "")):
978
+ raise SystemExit(_trae_command_space_error(entry.get("command", "")))
979
+ mode_note = _mcp_mode_note(entry, python_mode, command_override, rel, checkout)
980
+ if os.path.exists(path):
981
+ try:
982
+ data = _read_json_file(path)
983
+ if not isinstance(data, dict):
984
+ raise ValueError("top-level JSON is not an object")
985
+ except Exception as e:
986
+ raise SystemExit(f"{path} exists but is not valid JSON ({e}); "
987
+ "fix or remove it first")
988
+ else:
989
+ data = {}
990
+ servers = data.setdefault("mcpServers", {})
991
+ if not isinstance(servers, dict):
992
+ raise SystemExit(f"{path} has 'mcpServers' that is not an object; fix it first")
993
+ # No-op if already equivalent — avoids dirtying the tracked portable .mcp.json.
994
+ existing = servers.get("sql-buddy")
995
+ if existing == entry:
996
+ print(f"sql-buddy already configured in {path} ({mode_note}); no change.")
997
+ return 0
998
+ if os.path.exists(path):
999
+ # Backups live under the user cache dir (never adjacent to a foreign
1000
+ # project's working tree where they could be committed).
1001
+ bak = _mcp_backup_path(path)
1002
+ shutil.copy2(path, bak)
1003
+ try:
1004
+ os.chmod(bak, 0o600)
1005
+ except OSError:
1006
+ pass
1007
+ print(f"Backed up existing config to {bak}")
1008
+ servers["sql-buddy"] = entry
1009
+ _write_json_file(path, data)
1010
+ print(f"Wrote sql-buddy into {path} "
1011
+ f"(client: {client}, scope: {scope}, mode: {mode_note})")
1012
+ if client == "trae" and scope == "project":
1013
+ print("Trae: enable Settings → MCP → 'project-level MCP', then restart.")
1014
+ else:
1015
+ print("Restart your MCP client to pick it up.")
1016
+ return 0
1017
+
1018
+
1019
+ # ---------------------------------------------------------------------------
1020
+ # help / dispatcher
1021
+ # ---------------------------------------------------------------------------
1022
+
1023
+ _USAGE = """\
1024
+ sql-buddy {ver} — read-only MCP database server
1025
+
1026
+ Usage:
1027
+ sql-buddy [serve|stdio] run the MCP server over stdio (default)
1028
+ sql-buddy version print version
1029
+ sql-buddy doctor [--connect] validate install + db.ini (+ live pings)
1030
+ sql-buddy update-check compare local checkout to the git remote
1031
+ sql-buddy mcp print [--python] [--command CMD] [--client claude|trae]
1032
+ print the MCP client config block
1033
+ sql-buddy mcp add [--client claude|trae] [--scope project|user] [--python] [--command CMD]
1034
+ write the config into a client's config file
1035
+ sql-buddy init interactive setup wizard
1036
+ sql-buddy help show this help
1037
+
1038
+ Bare `sql-buddy` (no args) starts the MCP server, so MCP client configs that
1039
+ invoke `sql-buddy` or `python server.py` keep working unchanged.
1040
+
1041
+ Clients:
1042
+ claude .mcp.json (project) or ~/.claude.json (user) [default]
1043
+ trae .trae/mcp.json (project) or Trae User/mcp.json (user)
1044
+ """
1045
+
1046
+
1047
+ def cmd_help() -> int:
1048
+ print(_USAGE.format(ver=get_version()))
1049
+ return 0
1050
+
1051
+
1052
+ def _run_server() -> int:
1053
+ # Lazy import: keeps subcommands lightweight and avoids importing the MCP
1054
+ # runtime / DB drivers when the user only asked for `version` or `doctor`.
1055
+ import server
1056
+ server.run_server()
1057
+ return 0
1058
+
1059
+
1060
+ def main(argv: list[str] | None = None) -> int:
1061
+ args = list(sys.argv[1:] if argv is None else argv)
1062
+ if not args or args[0] in ("serve", "server", "stdio"):
1063
+ return _run_server()
1064
+
1065
+ cmd, rest = args[0], args[1:]
1066
+
1067
+ if cmd in ("version", "--version", "-V"):
1068
+ return cmd_version()
1069
+ if cmd in ("help", "--help", "-h", "-?"):
1070
+ return cmd_help()
1071
+ if cmd == "doctor":
1072
+ connect = "--connect" in rest
1073
+ return doctor(connect=connect)
1074
+ if cmd == "update-check":
1075
+ return update_check()
1076
+ if cmd == "init":
1077
+ import init
1078
+ # Pass `rest` explicitly: init.main() uses argparse, which reads
1079
+ # sys.argv — so we must not leave the literal "init" token in argv or
1080
+ # argparse rejects it as an unrecognized argument.
1081
+ return init.main(rest) or 0
1082
+ if cmd == "mcp":
1083
+ if not rest:
1084
+ print("Usage: sql-buddy mcp <print|add> [--python] [--command CMD] "
1085
+ "[--client claude|trae] [--scope project|user]",
1086
+ file=sys.stderr)
1087
+ return 2
1088
+ sub = rest[0]
1089
+ flags = rest[1:]
1090
+ try:
1091
+ parsed = _parse_mcp_flags(flags)
1092
+ except SystemExit as e:
1093
+ print(str(e) or "bad flags", file=sys.stderr)
1094
+ return 2
1095
+ if sub == "print":
1096
+ return cmd_mcp_print(parsed["python"], client=parsed["client"],
1097
+ command_override=parsed["command"])
1098
+ if sub == "add":
1099
+ return cmd_mcp_add(parsed["python"], client=parsed["client"],
1100
+ scope=parsed["scope"],
1101
+ command_override=parsed["command"])
1102
+ print(f"Unknown 'mcp' subcommand: {sub} (want print|add)", file=sys.stderr)
1103
+ return 2
1104
+
1105
+ print(f"Unknown command: {cmd}", file=sys.stderr)
1106
+ cmd_help()
1107
+ return 2
1108
+
1109
+
1110
+ def _parse_mcp_flags(flags: list[str]) -> dict:
1111
+ """Strict flag parser for `mcp print|add`. Unknown / valueless flags error."""
1112
+ out = {"python": False, "client": "claude", "scope": "project", "command": None}
1113
+ known = {"--python", "--client", "--scope", "--command"}
1114
+ i = 0
1115
+ while i < len(flags):
1116
+ f = flags[i]
1117
+ if f == "--python":
1118
+ out["python"] = True
1119
+ i += 1
1120
+ continue
1121
+ if f.startswith("--command="):
1122
+ out["command"] = f[len("--command="):]
1123
+ if not out["command"]:
1124
+ raise SystemExit("--command requires a value")
1125
+ i += 1
1126
+ continue
1127
+ if f == "--command":
1128
+ # A command string may itself contain leading-dash tokens, so do
1129
+ # NOT treat a '-'-prefixed next token as a missing value here.
1130
+ if i + 1 >= len(flags):
1131
+ raise SystemExit("--command requires a value (got end of args)")
1132
+ out["command"] = flags[i + 1]
1133
+ i += 2
1134
+ continue
1135
+ if f.startswith("--client=") or f.startswith("--scope="):
1136
+ name, _, val = f.partition("=")
1137
+ key = name[2:] # strip --
1138
+ if not val:
1139
+ raise SystemExit(f"{name} requires a value")
1140
+ out[key] = val
1141
+ i += 1
1142
+ continue
1143
+ if f in ("--client", "--scope"):
1144
+ if i + 1 >= len(flags) or flags[i + 1].startswith("-"):
1145
+ raise SystemExit(f"{f} requires a value (got end of args)")
1146
+ out[f[2:]] = flags[i + 1]
1147
+ i += 2
1148
+ continue
1149
+ if f.startswith("-"):
1150
+ raise SystemExit(f"unknown flag: {f} (want {', '.join(sorted(known))})")
1151
+ raise SystemExit(f"unexpected argument: {f}")
1152
+ out["client"] = (out["client"] or "").lower()
1153
+ if out["scope"] not in ("project", "user"):
1154
+ raise SystemExit(f"unsupported --scope '{out['scope']}' (want project|user)")
1155
+ if out["client"] not in _SUPPORTED_CLIENTS:
1156
+ raise SystemExit(
1157
+ f"unsupported --client '{out['client']}' "
1158
+ f"(want {', '.join(_SUPPORTED_CLIENTS)})"
1159
+ )
1160
+ return out
1161
+
1162
+
1163
+ if __name__ == "__main__":
1164
+ sys.exit(main())