bathys 0.6.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.
bathys/doctor.py ADDED
@@ -0,0 +1,139 @@
1
+ """F-305: `bathys-doctor` — one-shot diagnostics of the local Bathys stack.
2
+
3
+ Checks (no MCP server needed, runs against the same env/config):
4
+ backend ping, native pin marker, cache stats, metrics journal, robots mode,
5
+ chromium presence (fast import probe; --full launches the browser).
6
+
7
+ Exit code: 0 = all critical checks pass, 1 = at least one failed.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import argparse
13
+ import asyncio
14
+ import sqlite3
15
+ import sys
16
+ import time
17
+ from pathlib import Path
18
+
19
+ from . import __version__
20
+ from .config import SEARXNG_REF, Config
21
+
22
+
23
+ def _ok(name: str, ok: bool, detail: str = "") -> bool:
24
+ mark = "OK " if ok else "FAIL"
25
+ line = f"[{mark}] {name}" + (f" — {detail}" if detail else "")
26
+ print(line)
27
+ return ok
28
+
29
+
30
+ def _cache_stats(path: Path) -> tuple[int, int]:
31
+ if not path.is_file():
32
+ return 0, 0
33
+ try:
34
+ db = sqlite3.connect(f"file:{path}?mode=ro", uri=True)
35
+ total, alive = db.execute(
36
+ "SELECT COUNT(*), SUM(CASE WHEN exp > ? THEN 1 ELSE 0 END) FROM cache",
37
+ (time.time(),),
38
+ ).fetchone()
39
+ db.close()
40
+ return int(total or 0), int(alive or 0)
41
+ except sqlite3.Error:
42
+ return -1, -1
43
+
44
+
45
+ async def _backend_ping(cfg: Config) -> tuple[bool, str]:
46
+ import httpx
47
+
48
+ try:
49
+ r = await asyncio.to_thread(
50
+ lambda: httpx.get(
51
+ cfg.searxng_url + "/search",
52
+ params={"q": "ping", "format": "json"}, timeout=5.0,
53
+ )
54
+ )
55
+ if r.status_code == 200:
56
+ return True, f"{cfg.searxng_url} answers JSON ({len(r.text)} chars)"
57
+ return False, f"HTTP {r.status_code} from {cfg.searxng_url}"
58
+ except Exception as e:
59
+ return False, f"{e.__class__.__name__}: {str(e)[:120]}"
60
+
61
+
62
+ async def run_checks(full: bool) -> int:
63
+ cfg = Config.load()
64
+ results: list[bool] = []
65
+
66
+ print(f"bathys-doctor · bathys {__version__} · python {sys.version.split()[0]}")
67
+ print(f"data={cfg.data_dir} cache={cfg.cache_dir} mode={cfg.start_mode} "
68
+ f"robots={'on' if cfg.respect_robots else 'off'} "
69
+ f"metrics={'on' if cfg.metrics else 'off'}")
70
+ print()
71
+
72
+ results.append(_ok("config loads", True, f"backend={cfg.searxng_url}"))
73
+
74
+ ping_ok, detail = await _backend_ping(cfg)
75
+ results.append(_ok("searxng backend", ping_ok, detail))
76
+ if not ping_ok and cfg.auto_start:
77
+ print(" hint: doctor does not auto-start the backend; run any tool "
78
+ "call once, or start SearXNG yourself")
79
+
80
+ marker = cfg.searxng_home / "repo" / ".bathys-ref"
81
+ if cfg.searxng_home.joinpath("repo").is_dir():
82
+ pinned = marker.read_text().strip() if marker.is_file() else "(none)"
83
+ results.append(_ok(
84
+ "searxng pin (F-302)",
85
+ pinned == cfg.searxng_ref,
86
+ f"marker={pinned[:12]}… expected={cfg.searxng_ref[:12]}…",
87
+ ))
88
+ else:
89
+ _ok("searxng pin (F-302)", True, "native home absent — external/docker mode")
90
+
91
+ total, alive = _cache_stats(cfg.cache_dir / "cache.db")
92
+ results.append(_ok("cache db", total >= 0,
93
+ "absent (first run?)" if total == 0 else
94
+ f"{alive}/{total} alive rows" if total > 0 else "unreadable"))
95
+
96
+ mpath = cfg.data_dir / "metrics.jsonl"
97
+ if mpath.is_file():
98
+ lines = sum(1 for _ in mpath.open("rb"))
99
+ results.append(_ok("metrics journal (F-304)", True, f"{lines} events"))
100
+ else:
101
+ results.append(_ok("metrics journal (F-304)", True,
102
+ "no events yet (appears after first tool call)"))
103
+
104
+ try:
105
+ from crawl4ai import AsyncWebCrawler # noqa: F401
106
+ results.append(_ok("crawl4ai import", True))
107
+ except Exception as e:
108
+ results.append(_ok("crawl4ai import", False, str(e)[:120]))
109
+
110
+ if full:
111
+ try:
112
+ from playwright.sync_api import sync_playwright
113
+
114
+ p = sync_playwright().start()
115
+ b = p.chromium.launch(headless=True)
116
+ b.close()
117
+ p.stop()
118
+ results.append(_ok("chromium launch (--full)", True))
119
+ except Exception as e:
120
+ results.append(_ok("chromium launch (--full)", False, str(e)[:160]))
121
+
122
+ failed = results.count(False)
123
+ print()
124
+ print(f"{len(results) - failed}/{len(results)} checks passed"
125
+ + ("" if not failed else " — see FAIL lines above"))
126
+ return 1 if failed else 0
127
+
128
+
129
+ def main() -> None:
130
+ ap = argparse.ArgumentParser(prog="bathys-doctor",
131
+ description="Bathys local stack diagnostics")
132
+ ap.add_argument("--full", action="store_true",
133
+ help="also launch headless chromium (slower)")
134
+ args = ap.parse_args()
135
+ raise SystemExit(asyncio.run(run_checks(args.full)))
136
+
137
+
138
+ if __name__ == "__main__":
139
+ main()
bathys/installer.py ADDED
@@ -0,0 +1,424 @@
1
+ """`bathys install` — native, idempotent one-command harness integration.
2
+
3
+ Detects installed harnesses by their standard config paths and registers the
4
+ MCP server there. Two config contours are supported:
5
+
6
+ * JSON — zcode, Claude Code/Desktop, Cursor, VS Code, Gemini CLI,
7
+ Cline/Roo/Kilo Code, Windsurf, opencode (each with its own schema);
8
+ * YAML — goose (`extensions` block) and hermes (`mcp_servers` block),
9
+ handled with a minimal built-in emitter/parser that preserves the rest
10
+ of the document as opaque text (no yaml dependency in core).
11
+
12
+ Pi (badlogic pi-mono) has no MCP config file: integrations are TypeScript
13
+ extensions. Bathys ships the harness drop-in for that path instead
14
+ (agents/HARNESS-DROPIN.md -> AGENTS.md), and `install` never writes to Pi.
15
+
16
+ Base directories follow platform conventions by default (see config.py):
17
+ data ~/.local/share/bathys, cache ~/.cache/bathys — overridable by env.
18
+
19
+ Manual wiring is always available: `bathys install --print-config` emits
20
+ snippets for every supported harness. Every automatic write is preceded by a
21
+ timestamped backup and is idempotent — re-running updates in place.
22
+
23
+ Exit codes: 0 = at least one target updated (or nothing to do), 1 = failure.
24
+ """
25
+
26
+ from __future__ import annotations
27
+
28
+ import argparse
29
+ import datetime
30
+ import json
31
+ import re
32
+ import shutil
33
+ import sys
34
+ from pathlib import Path
35
+
36
+ _HOME = Path.home()
37
+ _PKG_DIR = Path(__file__).resolve().parent
38
+
39
+ # VS Code-family globalStorage lives under different roots per platform.
40
+ _VSCODE_GLOBAL = {
41
+ "linux": _HOME / ".config" / "Code" / "User" / "globalStorage",
42
+ "darwin": _HOME / "Library" / "Application Support" / "Code" / "User" / "globalStorage",
43
+ }
44
+
45
+
46
+ def _vscode_globalstorage() -> Path | None:
47
+ import platform
48
+
49
+ return _VSCODE_GLOBAL.get(platform.system().lower())
50
+
51
+
52
+ # name -> (config path, dotpath to servers/root, entry format)
53
+ # Formats:
54
+ # zcode {type:stdio, command, args, env}
55
+ # openai {command, env} (claude-code, claude-desktop,
56
+ # cursor, gemini, windsurf)
57
+ # vscode {command, args, env} under "servers"
58
+ # cline {command, args, env, disabled:false} under mcpServers
59
+ # opencode {type:local, command:[...], enabled, environment}
60
+ # goose YAML extensions.<name> {type:stdio, cmd, args, envs, enabled}
61
+ # hermes YAML mcp_servers.<name> {command, args, env}
62
+ _HARNESS_TARGETS: dict[str, tuple[Path, str, str]] = {
63
+ "zcode": (_HOME / ".zcode" / "cli" / "config.json", "mcp.servers", "zcode"),
64
+ "claude-code": (_HOME / ".claude.json", "mcpServers", "openai"),
65
+ "cursor": (_HOME / ".cursor" / "mcp.json", "mcpServers", "openai"),
66
+ "claude-desktop": (
67
+ _HOME / "Library" / "Application Support" / "Claude" / "claude_desktop_config.json",
68
+ "mcpServers", "openai",
69
+ ),
70
+ "gemini-cli": (_HOME / ".gemini" / "settings.json", "mcpServers", "openai"),
71
+ "windsurf": (_HOME / ".codeium" / "windsurf" / "mcp_config.json", "mcpServers", "openai"),
72
+ "zed": (_HOME / ".config" / "zed" / "settings.json", "context_servers", "openai"),
73
+ "opencode": (_HOME / ".config" / "opencode" / "opencode.json", "mcp", "opencode"),
74
+ }
75
+
76
+
77
+ def _cline_targets() -> dict[str, tuple[Path, str, str]]:
78
+ """Cline-family: each variant keeps its own file inside VS Code globalStorage."""
79
+ base = _vscode_globalstorage()
80
+ if base is None:
81
+ return {}
82
+ variants = {
83
+ "cline": "saoudrizwan.claude-dev/settings/cline_mcp_settings.json",
84
+ "roo-code": "rooveterinaryinc.roo-cline/settings/roo_mcp_settings.json",
85
+ "kilo-code": "kilocode.kilo-Code/settings/kilo_mcp_settings.json",
86
+ }
87
+ return {
88
+ name: (base / rel, "mcpServers", "cline")
89
+ for name, rel in variants.items()
90
+ }
91
+
92
+
93
+ def _yaml_targets() -> dict[str, tuple[Path, str, str]]:
94
+ return {
95
+ "goose": (_HOME / ".config" / "goose" / "config.yaml", "extensions", "goose"),
96
+ "hermes": (_HOME / ".hermes" / "config.yaml", "mcp_servers", "hermes"),
97
+ }
98
+
99
+
100
+ def _bathys_command() -> str:
101
+ """Absolute entry-point path: venv bin if editable, else console script."""
102
+ launcher = Path(sys.executable).parent / "bathys"
103
+ if launcher.is_file():
104
+ return str(launcher)
105
+ return str(_PKG_DIR.parent / ".venv" / "bin" / "bathys") if (
106
+ _PKG_DIR.parent / ".venv" / "bin" / "bathys").is_file() else "bathys"
107
+
108
+
109
+ def _server_entry(fmt: str, searxng_home: str | None) -> dict:
110
+ cmd = _bathys_command()
111
+ env: dict[str, str] = {}
112
+ if searxng_home:
113
+ env["BATHYS_SEARXNG_HOME"] = searxng_home
114
+ if fmt == "zcode":
115
+ entry = {"type": "stdio", "command": cmd, "args": []}
116
+ elif fmt == "vscode":
117
+ entry = {"type": "stdio", "command": cmd, "args": []}
118
+ elif fmt == "cline":
119
+ entry = {"command": cmd, "args": [], "disabled": False}
120
+ elif fmt == "opencode":
121
+ entry = {"type": "local", "command": [cmd], "enabled": True}
122
+ else: # openai, and the JSON-shaped part of yaml formats
123
+ entry = {"command": cmd}
124
+ if fmt == "opencode":
125
+ if env:
126
+ entry["environment"] = env
127
+ elif env:
128
+ entry["env"] = env
129
+ return entry
130
+
131
+
132
+ # ---------------------------------------------------------------- YAML ----
133
+ # Minimal, dependency-free contour for goose/hermes: read the target block as
134
+ # plain text, replace/append our entry, keep everything else byte-identical.
135
+ _YAML_BLOCK_HDR = re.compile(r"^([A-Za-z_][\w-]*):\s*$")
136
+
137
+
138
+ def _yaml_set(text: str, root: str, name: str, entry_lines: list[str],
139
+ fmt: str) -> str:
140
+ """Set root.<name> in a flat-roots YAML document (goose/hermes style)."""
141
+ lines = text.splitlines()
142
+ root_start = None
143
+ for i, ln in enumerate(lines):
144
+ m = _YAML_BLOCK_HDR.match(ln)
145
+ if m and m.group(1) == root:
146
+ root_start = i
147
+ break
148
+ indented = [" " + l for l in entry_lines] # 4 spaces: inside <name>:
149
+ block = [f" {name}:"] + indented
150
+ if root_start is None:
151
+ # append a new root at the end of the document, entry named inside
152
+ sep = [""] if text and not text.endswith("\n\n") else []
153
+ new_block = [f"{root}:"] + block
154
+ return text.rstrip("\n") + "\n" + "\n".join(sep + new_block) + "\n"
155
+ # find the end of the root block (next top-level key)
156
+ root_end = len(lines)
157
+ for j in range(root_start + 1, len(lines)):
158
+ if lines[j] and not lines[0].startswith(" ") and _YAML_BLOCK_HDR.match(lines[j]):
159
+ root_end = j
160
+ break
161
+ if lines[j] and not lines[j].startswith((" ", "\t", "-", "#")) and ":" in lines[j]:
162
+ root_end = j
163
+ break
164
+ # existing entry?
165
+ entry_pat = re.compile(rf"^ {re.escape(name)}:\s*(#.*)?$")
166
+ for j in range(root_start + 1, root_end):
167
+ if entry_pat.match(lines[j]):
168
+ # replace from entry line to the next sibling (2-space indent)
169
+ k = j + 1
170
+ while k < root_end and (lines[k].startswith(" ") or not lines[k].strip()):
171
+ k += 1
172
+ lines[j:k] = block
173
+ return "\n".join(lines) + "\n"
174
+ # insert at the end of the root block
175
+ insert_at = root_end
176
+ lines[insert_at:insert_at] = block
177
+ return "\n".join(lines) + "\n"
178
+
179
+
180
+ def _yaml_entry_lines(fmt: str, searxng_home: str | None) -> list[str]:
181
+ cmd = _bathys_command()
182
+ if fmt == "goose":
183
+ lines = ["type: stdio", "name: bathys", "enabled: true",
184
+ f'cmd: "{cmd}"', "args: []", "envs: {}"]
185
+ if searxng_home:
186
+ lines[-1] = "envs:"
187
+ lines.append(f' BATHYS_SEARXNG_HOME: "{searxng_home}"')
188
+ return lines
189
+ # hermes
190
+ lines = [f'command: "{cmd}"', "args: []", "env: {}"]
191
+ if searxng_home:
192
+ lines[-1] = "env:"
193
+ lines.append(f' BATHYS_SEARXNG_HOME: "{searxng_home}"')
194
+ return lines
195
+
196
+
197
+ def _yaml_current_ok(text: str, root: str, name: str, fmt: str,
198
+ searxng_home: str | None) -> bool:
199
+ """True when root.<name> equals the desired entry block exactly."""
200
+ desired_lines = _yaml_entry_lines(fmt, searxng_home)
201
+ lines = text.splitlines()
202
+ in_root = False
203
+ i = 0
204
+ while i < len(lines):
205
+ ln = lines[i]
206
+ m = _YAML_BLOCK_HDR.match(ln)
207
+ if m and m.group(1) == root:
208
+ in_root = True
209
+ elif m and in_root:
210
+ in_root = False
211
+ if in_root and re.match(rf"^ {re.escape(name)}:\s*(#.*)?$", ln):
212
+ got = []
213
+ j = i + 1
214
+ while j < len(lines) and (not lines[j].strip() or lines[j].startswith(" ")):
215
+ if lines[j].strip():
216
+ got.append(lines[j].strip())
217
+ j += 1
218
+ return [g.strip() for g in got] == [d.strip() for d in desired_lines]
219
+ i += 1
220
+ return False
221
+
222
+
223
+ # --------------------------------------------------------------- shared ----
224
+
225
+ def _get_by_path(obj: object, dotpath: str) -> dict | None:
226
+ cur: object = obj
227
+ for part in dotpath.split("."):
228
+ if not isinstance(cur, dict) or part not in cur:
229
+ return None
230
+ cur = cur[part]
231
+ return cur if isinstance(cur, dict) else None
232
+
233
+
234
+ def _ensure_path(obj: dict, dotpath: str) -> dict:
235
+ cur = obj
236
+ for part in dotpath.split("."):
237
+ cur = cur.setdefault(part, {})
238
+ return cur
239
+
240
+
241
+ def _backup(path: Path) -> Path | None:
242
+ if not path.is_file():
243
+ return None
244
+ stamp = datetime.datetime.now().strftime("%Y%m%d-%H%M%S")
245
+ bkp = path.with_name(f"{path.name}.bathys-backup-{stamp}")
246
+ shutil.copy2(path, bkp)
247
+ return bkp
248
+
249
+
250
+ def _entry_needs_update(existing: dict, desired: dict) -> bool:
251
+ if not isinstance(existing, dict):
252
+ return True
253
+ for key in ("command", "type", "cmd"):
254
+ if key in desired and existing.get(key) != desired[key]:
255
+ return True
256
+ old_env = existing.get("env") or existing.get("environment") or existing.get("envs") or {}
257
+ new_env = desired.get("env") or desired.get("environment") or desired.get("envs") or {}
258
+ return any(new_env[k] != old_env.get(k) for k in new_env)
259
+
260
+
261
+ def _all_targets() -> dict[str, tuple[Path, str, str]]:
262
+ t = dict(_HARNESS_TARGETS)
263
+ t.update(_cline_targets())
264
+ t.update(_yaml_targets())
265
+ return t
266
+
267
+
268
+ def _agent_src() -> Path | None:
269
+ """Locate agents/bathys-researcher.md: repo root (editable install),
270
+ packaged copy (wheel force-include), or CWD (running from a clone)."""
271
+ name = Path("agents") / "bathys-researcher.md"
272
+ pkg_dir = _PKG_DIR # .../src/bathys or site-packages/bathys
273
+ for base in (pkg_dir.parent.parent, pkg_dir, Path.cwd()):
274
+ cand = base / name
275
+ if cand.is_file():
276
+ return cand
277
+ return None
278
+
279
+
280
+ def install(dry_run: bool, print_config: bool, with_agent: bool,
281
+ searxng_home: str | None,
282
+ _targets: dict[str, tuple[Path, str, str]] | None = None) -> int:
283
+ """_targets exists for tests: a full harness registry to substitute
284
+ (detection paths are machine-specific). Production callers omit it."""
285
+ all_targets = dict(_targets) if _targets is not None else _all_targets()
286
+ if print_config:
287
+ print("# Bathys — блоки для ручного подключения\n")
288
+ for name, (path, dotpath, fmt) in _all_targets().items():
289
+ print(f"## {name} — {path}")
290
+ if fmt in ("goose", "hermes"):
291
+ lines = _yaml_entry_lines(fmt, searxng_home)
292
+ print(f"{dotpath}:")
293
+ print(" bathys:")
294
+ print("\n".join(" " + l for l in lines))
295
+ else:
296
+ # nest the snippet under the full dotpath (e.g. mcp.servers)
297
+ snippet: dict = {"bathys": _server_entry(fmt, searxng_home)}
298
+ for part in reversed(dotpath.split(".")):
299
+ snippet = {part: snippet}
300
+ print(json.dumps(snippet, indent=2, ensure_ascii=False))
301
+ print()
302
+ print("# Pi (badlogic pi-mono) не имеет MCP-конфига: вставьте дроп-ин "
303
+ "agents/HARNESS-DROPIN.md в AGENTS.md проекта.")
304
+ return 0
305
+
306
+ json_targets = {n: t for n, t in all_targets.items() if t[2] not in ("goose", "hermes")}
307
+ yaml_targets = {n: t for n, t in all_targets.items() if t[2] in ("goose", "hermes")}
308
+
309
+ found_json = {n: t for n, t in json_targets.items() if t[0].is_file()}
310
+ found_yaml = {n: t for n, t in yaml_targets.items() if t[0].is_file()}
311
+ found_zed_dir = (_HOME / ".config" / "zed").is_dir()
312
+ if found_zed_dir and "zed" not in found_json:
313
+ # Zed commonly has no settings.json yet — creating it is safe: it is
314
+ # user-editable and Zed merges defaults for missing keys.
315
+ found_json["zed"] = json_targets["zed"]
316
+
317
+ if not found_json and not found_yaml:
318
+ print("Харнессы не найдены по стандартным путям. Ручное подключение:")
319
+ print(" bathys install --print-config # готовые блоки для вставки")
320
+ return 0
321
+
322
+ updated: list[str] = []
323
+ skipped: list[str] = []
324
+
325
+ for name, (path, dotpath, fmt) in found_json.items():
326
+ desired = _server_entry(fmt, searxng_home)
327
+ try:
328
+ data = json.loads(path.read_text(encoding="utf-8"))
329
+ except (json.JSONDecodeError, OSError) as e:
330
+ print(f"[SKIP] {name}: {path} не читается ({e.__class__.__name__})")
331
+ continue
332
+ current = (_get_by_path(data, dotpath) or {}).get("bathys")
333
+ if current == desired or not _entry_needs_update(current or {}, desired):
334
+ skipped.append(name)
335
+ continue
336
+ if dry_run:
337
+ updated.append(name)
338
+ continue
339
+ bkp = _backup(path)
340
+ servers = _ensure_path(data, dotpath)
341
+ servers["bathys"] = desired
342
+ path.write_text(json.dumps(data, indent=2, ensure_ascii=False) + "\n",
343
+ encoding="utf-8")
344
+ note = f" (бэкап: {bkp.name})" if bkp else ""
345
+ print(f"[OK] {name}: bathys прописан в {path}{note}")
346
+ updated.append(name)
347
+
348
+ for name, (path, dotpath, fmt) in found_yaml.items():
349
+ try:
350
+ text = path.read_text(encoding="utf-8")
351
+ except OSError as e:
352
+ print(f"[SKIP] {name}: {path} не читается ({e.__class__.__name__})")
353
+ continue
354
+ if _yaml_current_ok(text, dotpath, "bathys", fmt, searxng_home):
355
+ skipped.append(name)
356
+ continue
357
+ if dry_run:
358
+ updated.append(name)
359
+ continue
360
+ bkp = _backup(path)
361
+ new_text = _yaml_set(text, dotpath, "bathys", _yaml_entry_lines(fmt, searxng_home), fmt)
362
+ path.write_text(new_text, encoding="utf-8")
363
+ note = f" (бэкап: {bkp.name})" if bkp else ""
364
+ print(f"[OK] {name}: bathys прописан в {path}{note}")
365
+ updated.append(name)
366
+
367
+ for name in skipped:
368
+ print(f"[=] {name}: уже актуален, не тронут")
369
+
370
+ if with_agent:
371
+ src = _agent_src()
372
+ if src is None:
373
+ print("[i] субагент не найден в установке; скопируйте agents/bathys-researcher.md вручную")
374
+ else:
375
+ dst_dirs = {
376
+ "zcode": _HOME / ".zcode" / "agents",
377
+ "claude-code": _HOME / ".claude" / "agents",
378
+ "goose": _HOME / ".config" / "goose" / "agents",
379
+ "hermes": _HOME / ".hermes" / "agents",
380
+ }
381
+ copied = False
382
+ for harness, dst in dst_dirs.items():
383
+ if harness in ("zcode",) or dst.parent.exists():
384
+ if dry_run:
385
+ print(f"[DRY] агент → {dst}")
386
+ copied = True
387
+ continue
388
+ dst.mkdir(parents=True, exist_ok=True)
389
+ shutil.copy2(src, dst / src.name)
390
+ print(f"[OK] субагент {src.name} → {dst}")
391
+ copied = True
392
+ if not copied:
393
+ print("[i] подходящих каталогов харнессов не найдено; скопируйте вручную "
394
+ f"{src}")
395
+
396
+ if dry_run:
397
+ print(f"[DRY] изменено бы: {', '.join(updated) or '—'}; актуально: {', '.join(skipped) or '—'}")
398
+ else:
399
+ print(f"Готово: обновлено {len(updated)}, без изменений {len(skipped)}.")
400
+ print("Перезапустите харнесс, чтобы он подхватил конфиг; поведение агента — `bathys install --print-config` и docs/getting-started/cases.md (раздел C).")
401
+ return 1 if not updated and not skipped else 0
402
+
403
+
404
+ def main() -> None:
405
+ ap = argparse.ArgumentParser(
406
+ prog="bathys install",
407
+ description="Нативная интеграция Bathys в харнессы: автодетект конфигов "
408
+ "(zcode, Claude, Cursor, VS Code/Cline/Roo/Kilo, Gemini CLI, "
409
+ "Windsurf, Zed, opencode, goose, hermes), идемпотентная запись "
410
+ "с бэкапом, опциональный субагент.")
411
+ ap.add_argument("--dry-run", action="store_true", help="показать план без записи")
412
+ ap.add_argument("--print-config", action="store_true",
413
+ help="напечатать блоки для ручного подключения и выйти")
414
+ ap.add_argument("--with-agent", action="store_true",
415
+ help="также скопировать субагента bathys-researcher")
416
+ ap.add_argument("--searxng-home", default=None,
417
+ help="путь BATHYS_SEARXNG_HOME (по умолчанию не писать)")
418
+ args = ap.parse_args()
419
+ raise SystemExit(install(args.dry_run, args.print_config, args.with_agent,
420
+ args.searxng_home))
421
+
422
+
423
+ if __name__ == "__main__":
424
+ main()