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/__init__.py +8 -0
- bathys/agents/HARNESS-DROPIN.md +50 -0
- bathys/agents/bathys-researcher.md +91 -0
- bathys/agents/skills/bathys-deep-dive/SKILL.md +55 -0
- bathys/agents/skills/bathys-source-audit/SKILL.md +48 -0
- bathys/batch.py +86 -0
- bathys/cache.py +47 -0
- bathys/compose.yaml +22 -0
- bathys/config.py +71 -0
- bathys/core.py +425 -0
- bathys/crawler.py +104 -0
- bathys/distill.py +135 -0
- bathys/doctor.py +139 -0
- bathys/installer.py +424 -0
- bathys/searx.py +149 -0
- bathys/searxng-settings.yml +13 -0
- bathys/server.py +399 -0
- bathys/services.py +263 -0
- bathys-0.6.1.dist-info/METADATA +147 -0
- bathys-0.6.1.dist-info/RECORD +23 -0
- bathys-0.6.1.dist-info/WHEEL +4 -0
- bathys-0.6.1.dist-info/entry_points.txt +4 -0
- bathys-0.6.1.dist-info/licenses/LICENSE +21 -0
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()
|