caura-bus-cli 0.3.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- caura_bus_cli/__init__.py +5 -0
- caura_bus_cli/broker.py +140 -0
- caura_bus_cli/hooks.py +115 -0
- caura_bus_cli/join.py +438 -0
- caura_bus_cli/main.py +530 -0
- caura_bus_cli/peer_instructions.md +243 -0
- caura_bus_cli/py.typed +0 -0
- caura_bus_cli/runtime.py +555 -0
- caura_bus_cli/setup.py +819 -0
- caura_bus_cli-0.3.0.dist-info/METADATA +16 -0
- caura_bus_cli-0.3.0.dist-info/RECORD +16 -0
- caura_bus_cli-0.3.0.dist-info/WHEEL +5 -0
- caura_bus_cli-0.3.0.dist-info/entry_points.txt +2 -0
- caura_bus_cli-0.3.0.dist-info/licenses/LICENSE +202 -0
- caura_bus_cli-0.3.0.dist-info/licenses/NOTICE +7 -0
- caura_bus_cli-0.3.0.dist-info/top_level.txt +1 -0
caura_bus_cli/setup.py
ADDED
|
@@ -0,0 +1,819 @@
|
|
|
1
|
+
"""`caura-bus setup`: connect a Claude Code or Codex runtime to Caura in one step.
|
|
2
|
+
|
|
3
|
+
Everything identity-related comes from the server: the key is verified first and
|
|
4
|
+
the effective agent and tenant are read back from it. Each step only rewrites
|
|
5
|
+
what setup owns (its agent config, the ``caura-bus`` MCP entry, the same
|
|
6
|
+
server's ``caura`` memory MCP entry, and a marked instruction block), so
|
|
7
|
+
re-running updates in place.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import json
|
|
13
|
+
import os
|
|
14
|
+
import re
|
|
15
|
+
import shlex
|
|
16
|
+
import shutil
|
|
17
|
+
import subprocess
|
|
18
|
+
import sys
|
|
19
|
+
import tomllib
|
|
20
|
+
from collections.abc import Callable
|
|
21
|
+
from dataclasses import dataclass, field
|
|
22
|
+
from importlib.resources import files
|
|
23
|
+
from pathlib import Path
|
|
24
|
+
from typing import Any
|
|
25
|
+
from urllib.parse import urlsplit
|
|
26
|
+
|
|
27
|
+
import httpx
|
|
28
|
+
from caura_bus_core import AgentConfig, Bus, PlatformError, load_config
|
|
29
|
+
|
|
30
|
+
SERVER_NAME = "caura-bus"
|
|
31
|
+
# The memory MCP of the same Caura server. Claude Code gets a local-scope
|
|
32
|
+
# ``caura`` entry, which overrides a user-scope one of the same name in this
|
|
33
|
+
# project. Codex has only a per-user config, so its entry is named per host.
|
|
34
|
+
MEMORY_SERVER_NAME = "caura"
|
|
35
|
+
MEMORY_PATH = "/mcp/"
|
|
36
|
+
MEMORY_HEADER = "X-API-Key"
|
|
37
|
+
# Entry names that usually hold a memory server pointing at some Caura.
|
|
38
|
+
MEMORY_LIKE_NAMES = ("caura", "memclaw") # legacy-name-floor: users' pre-rename MCP entry name
|
|
39
|
+
MARK_START = "<!-- caura-bus:peer-instructions:start"
|
|
40
|
+
MARK_END = "<!-- caura-bus:peer-instructions:end -->"
|
|
41
|
+
MARK_HEADER = MARK_START + " (managed by `caura-bus setup`; re-running setup replaces this block) -->"
|
|
42
|
+
LOCAL_HOSTS = {"localhost", "127.0.0.1", "::1"}
|
|
43
|
+
RUNTIMES = {"claude": "claude", "claude-code": "claude", "codex": "codex"}
|
|
44
|
+
INSTRUCTION_FILES = {"claude": "CLAUDE.md", "codex": "AGENTS.md"}
|
|
45
|
+
RUNTIME_NAMES = {"claude": "Claude Code", "codex": "Codex"}
|
|
46
|
+
NEXT_HEADER = re.compile(r"^[ \t]*\[", re.M)
|
|
47
|
+
OWNED_KEYS = re.compile(r"^[ \t]*(?:command|args|env)[ \t]*=")
|
|
48
|
+
MEMORY_OWNED_KEYS = re.compile(r"^[ \t]*(?:url|http_headers|command|args|env)[ \t]*=")
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def codex_header(name: str) -> re.Pattern[str]:
|
|
52
|
+
quoted = re.escape(name)
|
|
53
|
+
return re.compile(rf'^\[\s*mcp_servers\s*\.\s*(?:"{quoted}"|{quoted})\s*\]\s*(?:#.*)?$', re.M)
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
CODEX_HEADER = codex_header(SERVER_NAME)
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
class SetupError(RuntimeError):
|
|
60
|
+
"""A step failed; the message is safe to print (keys are masked)."""
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def mask(key: str) -> str:
|
|
64
|
+
key = key.strip()
|
|
65
|
+
return f"{key[:6]}…{key[-4:]}" if len(key) >= 16 else "****"
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def scrub(text: str, key: str) -> str:
|
|
69
|
+
return text.replace(key, mask(key)) if key else text
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def show(path: Path | str) -> str:
|
|
73
|
+
"""Shorten a path under HOME to ~/... for display (never for commands with VAR=path)."""
|
|
74
|
+
text, home = str(path), str(Path.home())
|
|
75
|
+
return "~" + text[len(home) :] if text == home or text.startswith(home + os.sep) else text
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def shell_path(path: Path) -> str:
|
|
79
|
+
"""A shell word for ``path``, keeping a leading ~/ expandable."""
|
|
80
|
+
shown = show(path)
|
|
81
|
+
return "~/" + shlex.quote(shown[2:]) if shown.startswith("~/") else shlex.quote(shown)
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def toml_str(value: str) -> str:
|
|
85
|
+
# JSON string escapes (\" \\ \n \uXXXX ...) are valid TOML basic-string escapes.
|
|
86
|
+
return json.dumps(value)
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def check_url(url: str) -> tuple[str, bool]:
|
|
90
|
+
"""Return the normalised origin and whether plain HTTP is allowed (localhost only)."""
|
|
91
|
+
parts = urlsplit(url.strip())
|
|
92
|
+
if parts.scheme not in {"https", "http"} or not parts.hostname:
|
|
93
|
+
raise SetupError(f"--url must be an http(s) origin such as https://caura.example, got {url!r}")
|
|
94
|
+
if parts.path not in {"", "/"} or parts.query or parts.fragment or parts.username or parts.password:
|
|
95
|
+
raise SetupError(
|
|
96
|
+
"--url must be the gateway origin only (no path such as /api/v1, query or credentials)"
|
|
97
|
+
)
|
|
98
|
+
insecure = parts.scheme == "http"
|
|
99
|
+
if insecure and parts.hostname not in LOCAL_HOSTS:
|
|
100
|
+
raise SetupError("plain http is allowed only for localhost; use https://")
|
|
101
|
+
return f"{parts.scheme}://{parts.netloc}", insecure
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def slug(value: str) -> str:
|
|
105
|
+
return re.sub(r"[^A-Za-z0-9._-]", "_", value).strip(".") or "_"
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def config_home() -> Path:
|
|
109
|
+
base = os.environ.get("XDG_CONFIG_HOME", "").strip()
|
|
110
|
+
return Path(base) if base else Path.home() / ".config"
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def default_config_path(url: str, tenant_id: str, agent_id: str) -> Path:
|
|
114
|
+
netloc = urlsplit(url).netloc
|
|
115
|
+
return config_home() / "caura-bus" / "agents" / slug(netloc) / slug(tenant_id) / f"{slug(agent_id)}.toml"
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def codex_config_path() -> Path:
|
|
119
|
+
base = os.environ.get("CODEX_HOME", "").strip()
|
|
120
|
+
return (Path(base) if base else Path.home() / ".codex") / "config.toml"
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def find_mcp_binary() -> Path | None:
|
|
124
|
+
"""Prefer the caura-bus-mcp installed next to this CLI (same version), then PATH."""
|
|
125
|
+
sibling = Path(sys.executable).parent / "caura-bus-mcp"
|
|
126
|
+
if sibling.is_file() and os.access(sibling, os.X_OK):
|
|
127
|
+
return sibling.absolute()
|
|
128
|
+
found = shutil.which("caura-bus-mcp")
|
|
129
|
+
return Path(found).absolute() if found else None
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def peer_template() -> str:
|
|
133
|
+
return files("caura_bus_cli").joinpath("peer_instructions.md").read_text()
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
# --- 1. verify the key ----------------------------------------------------
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
async def verify(url: str, insecure: bool, key: str, transport: httpx.AsyncBaseTransport | None = None):
|
|
140
|
+
"""Read the effective identity, then re-check it the way `doctor` does."""
|
|
141
|
+
probe = AgentConfig(
|
|
142
|
+
api_url=url,
|
|
143
|
+
allow_insecure_http=insecure,
|
|
144
|
+
agent={"agent_id": "unverified", "tenant_id": "unverified"},
|
|
145
|
+
)
|
|
146
|
+
bus = Bus(probe, api_key=key, transport=transport)
|
|
147
|
+
try:
|
|
148
|
+
identity = await bus.request("GET", "identity")
|
|
149
|
+
finally:
|
|
150
|
+
await bus.close()
|
|
151
|
+
if not isinstance(identity, dict) or not identity.get("agent_id") or not identity.get("tenant_id"):
|
|
152
|
+
raise SetupError("the server did not return an agent identity; is --url a Caura gateway?")
|
|
153
|
+
return identity
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
async def describe(config: AgentConfig, key: str, text: str, transport=None) -> dict:
|
|
157
|
+
async with Bus(config, api_key=key, transport=transport) as bus:
|
|
158
|
+
return await bus.describe(text)
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
# --- 2. agent config ------------------------------------------------------
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
def render_config(url: str, insecure: bool, identity: dict, existing: Path) -> str:
|
|
165
|
+
peers: list[str] = ["*"]
|
|
166
|
+
consultation: dict[str, Any] = {}
|
|
167
|
+
if existing.is_file():
|
|
168
|
+
try:
|
|
169
|
+
old = tomllib.loads(existing.read_text())
|
|
170
|
+
except (tomllib.TOMLDecodeError, OSError):
|
|
171
|
+
old = {}
|
|
172
|
+
if isinstance(old.get("peers"), list):
|
|
173
|
+
peers = [str(p) for p in old["peers"]]
|
|
174
|
+
if isinstance(old.get("consultation"), dict):
|
|
175
|
+
consultation = old["consultation"]
|
|
176
|
+
lines = [
|
|
177
|
+
"# Managed by `caura-bus setup`; re-run it to update. The API key is not stored here.",
|
|
178
|
+
f"api_url = {toml_str(url)}",
|
|
179
|
+
]
|
|
180
|
+
if insecure:
|
|
181
|
+
lines.append("allow_insecure_http = true")
|
|
182
|
+
lines.append("peers = [" + ", ".join(toml_str(p) for p in peers) + "]")
|
|
183
|
+
lines += [
|
|
184
|
+
"",
|
|
185
|
+
"[agent]",
|
|
186
|
+
f"agent_id = {toml_str(identity['agent_id'])}",
|
|
187
|
+
f"tenant_id = {toml_str(identity['tenant_id'])}",
|
|
188
|
+
]
|
|
189
|
+
if identity.get("fleet_id"):
|
|
190
|
+
lines.append(f"fleet_id = {toml_str(str(identity['fleet_id']))}")
|
|
191
|
+
if consultation:
|
|
192
|
+
lines += ["", "[consultation]"]
|
|
193
|
+
for name in ("max_requests", "deadline_seconds"):
|
|
194
|
+
if isinstance(consultation.get(name), int | float) and not isinstance(
|
|
195
|
+
consultation.get(name), bool
|
|
196
|
+
):
|
|
197
|
+
lines.append(f"{name} = {consultation[name]}")
|
|
198
|
+
return "\n".join(lines) + "\n"
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
def write_private(path: Path, text: str) -> None:
|
|
202
|
+
path.parent.mkdir(parents=True, exist_ok=True, mode=0o700)
|
|
203
|
+
temporary = path.with_name(path.name + ".tmp")
|
|
204
|
+
fd = os.open(temporary, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600)
|
|
205
|
+
with os.fdopen(fd, "w") as handle:
|
|
206
|
+
handle.write(text)
|
|
207
|
+
os.chmod(temporary, 0o600)
|
|
208
|
+
temporary.replace(path)
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
# --- 3. runtime wiring ----------------------------------------------------
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
def claude_add_argv(claude: str, key: str, config: Path, mcp: Path) -> list[str]:
|
|
215
|
+
return [
|
|
216
|
+
claude,
|
|
217
|
+
"mcp",
|
|
218
|
+
"add",
|
|
219
|
+
"--scope",
|
|
220
|
+
"local",
|
|
221
|
+
SERVER_NAME,
|
|
222
|
+
"-e",
|
|
223
|
+
f"CAURA_API_KEY={key}",
|
|
224
|
+
"-e",
|
|
225
|
+
f"CAURA_BUS_AGENT_CONFIG={config}",
|
|
226
|
+
"--",
|
|
227
|
+
str(mcp),
|
|
228
|
+
]
|
|
229
|
+
|
|
230
|
+
|
|
231
|
+
def claude_manual_command(directory: Path, config: Path, mcp: Path) -> str:
|
|
232
|
+
"""The exact command to run by hand; the key comes from the user's environment."""
|
|
233
|
+
argv = claude_add_argv("claude", "KEYPLACEHOLDER", config, mcp)
|
|
234
|
+
command = shlex.join(argv).replace(
|
|
235
|
+
"CAURA_API_KEY=KEYPLACEHOLDER", '"CAURA_API_KEY=${CAURA_API_KEY:?export your agent key first}"'
|
|
236
|
+
)
|
|
237
|
+
return f"cd {shlex.quote(str(directory))} && {command}"
|
|
238
|
+
|
|
239
|
+
|
|
240
|
+
def wire_claude(directory: Path, key: str, config: Path, mcp: Path) -> str:
|
|
241
|
+
claude = shutil.which("claude")
|
|
242
|
+
if not claude:
|
|
243
|
+
raise SetupError(
|
|
244
|
+
"`claude` is not on PATH. Run this yourself (with CAURA_API_KEY exported):\n "
|
|
245
|
+
+ claude_manual_command(directory, config, mcp)
|
|
246
|
+
)
|
|
247
|
+
# Replace a previous entry so re-running updates the key and paths in place.
|
|
248
|
+
subprocess.run(
|
|
249
|
+
[claude, "mcp", "remove", "--scope", "local", SERVER_NAME],
|
|
250
|
+
cwd=directory,
|
|
251
|
+
capture_output=True,
|
|
252
|
+
text=True,
|
|
253
|
+
check=False,
|
|
254
|
+
)
|
|
255
|
+
result = subprocess.run(
|
|
256
|
+
claude_add_argv(claude, key, config, mcp),
|
|
257
|
+
cwd=directory,
|
|
258
|
+
capture_output=True,
|
|
259
|
+
text=True,
|
|
260
|
+
check=False,
|
|
261
|
+
)
|
|
262
|
+
if result.returncode != 0:
|
|
263
|
+
detail = scrub((result.stderr or result.stdout).strip(), key)
|
|
264
|
+
raise SetupError(f"`claude mcp add` failed ({result.returncode}): {detail}")
|
|
265
|
+
return f"claude mcp add --scope local {SERVER_NAME} (project {show(directory)})"
|
|
266
|
+
|
|
267
|
+
|
|
268
|
+
def codex_block(key: str, config: Path, mcp: Path) -> list[str]:
|
|
269
|
+
return [
|
|
270
|
+
f"command = {toml_str(str(mcp))}",
|
|
271
|
+
"env = { CAURA_API_KEY = "
|
|
272
|
+
+ toml_str(key)
|
|
273
|
+
+ ", CAURA_BUS_AGENT_CONFIG = "
|
|
274
|
+
+ toml_str(str(config))
|
|
275
|
+
+ " }",
|
|
276
|
+
]
|
|
277
|
+
|
|
278
|
+
|
|
279
|
+
def without_table(data: dict, name: str, owned: tuple[str, ...]) -> dict:
|
|
280
|
+
data = json.loads(json.dumps(data, default=str))
|
|
281
|
+
entry = data.get("mcp_servers", {}).get(name)
|
|
282
|
+
if isinstance(entry, dict):
|
|
283
|
+
for key in owned:
|
|
284
|
+
entry.pop(key, None)
|
|
285
|
+
if not entry:
|
|
286
|
+
data["mcp_servers"].pop(name)
|
|
287
|
+
if not data["mcp_servers"]:
|
|
288
|
+
data.pop("mcp_servers")
|
|
289
|
+
return data
|
|
290
|
+
|
|
291
|
+
|
|
292
|
+
@dataclass
|
|
293
|
+
class CodexTable:
|
|
294
|
+
"""One ``[mcp_servers.<name>]`` table setup owns some keys of."""
|
|
295
|
+
|
|
296
|
+
name: str
|
|
297
|
+
lines: list[str]
|
|
298
|
+
owned: tuple[str, ...]
|
|
299
|
+
owned_pattern: re.Pattern[str]
|
|
300
|
+
expected: dict[str, Any]
|
|
301
|
+
|
|
302
|
+
|
|
303
|
+
def bus_table(key: str, config: Path, mcp: Path) -> CodexTable:
|
|
304
|
+
return CodexTable(
|
|
305
|
+
name=SERVER_NAME,
|
|
306
|
+
lines=codex_block(key, config, mcp),
|
|
307
|
+
owned=("command", "args", "env"),
|
|
308
|
+
owned_pattern=OWNED_KEYS,
|
|
309
|
+
expected={"command": str(mcp), "env": {"CAURA_API_KEY": key, "CAURA_BUS_AGENT_CONFIG": str(config)}},
|
|
310
|
+
)
|
|
311
|
+
|
|
312
|
+
|
|
313
|
+
def memory_table(url: str, key: str) -> CodexTable:
|
|
314
|
+
endpoint = memory_url(url)
|
|
315
|
+
return CodexTable(
|
|
316
|
+
name=codex_memory_name(url),
|
|
317
|
+
lines=[
|
|
318
|
+
f"url = {toml_str(endpoint)}",
|
|
319
|
+
"http_headers = { " + toml_str(MEMORY_HEADER) + " = " + toml_str(key) + " }",
|
|
320
|
+
],
|
|
321
|
+
owned=("url", "http_headers", "command", "args", "env"),
|
|
322
|
+
owned_pattern=MEMORY_OWNED_KEYS,
|
|
323
|
+
expected={"url": endpoint, "http_headers": {MEMORY_HEADER: key}},
|
|
324
|
+
)
|
|
325
|
+
|
|
326
|
+
|
|
327
|
+
def update_codex_table(text: str, table: CodexTable) -> str:
|
|
328
|
+
"""Add or update only [mcp_servers.<name>]; refuse anything that would change more."""
|
|
329
|
+
name = table.name
|
|
330
|
+
try:
|
|
331
|
+
before = tomllib.loads(text)
|
|
332
|
+
except tomllib.TOMLDecodeError as exc:
|
|
333
|
+
raise SetupError(f"existing Codex config is not valid TOML ({exc}); not modified") from None
|
|
334
|
+
match = codex_header(name).search(text)
|
|
335
|
+
if match is None:
|
|
336
|
+
if name in before.get("mcp_servers", {}):
|
|
337
|
+
raise SetupError(
|
|
338
|
+
f"Codex config defines mcp_servers.{name} without a [mcp_servers.{name}] "
|
|
339
|
+
"table header; edit it by hand or move it into that table, then re-run setup"
|
|
340
|
+
)
|
|
341
|
+
updated = (text.rstrip("\n") + "\n\n" if text.strip() else "") + "\n".join(
|
|
342
|
+
[f"[mcp_servers.{name}]", *table.lines]
|
|
343
|
+
)
|
|
344
|
+
updated += "\n"
|
|
345
|
+
else:
|
|
346
|
+
start = match.end() + 1 if match.end() < len(text) else match.end()
|
|
347
|
+
following = NEXT_HEADER.search(text, start)
|
|
348
|
+
end = following.start() if following else len(text)
|
|
349
|
+
kept = [line for line in text[start:end].splitlines() if not table.owned_pattern.match(line)]
|
|
350
|
+
body = "\n".join([*table.lines, *kept]).rstrip("\n") + "\n"
|
|
351
|
+
if following:
|
|
352
|
+
body += "\n"
|
|
353
|
+
prefix = text[:start] if text[:start].endswith("\n") else text[:start] + "\n"
|
|
354
|
+
updated = prefix + body + text[end:].lstrip("\n")
|
|
355
|
+
try:
|
|
356
|
+
after = tomllib.loads(updated)
|
|
357
|
+
except tomllib.TOMLDecodeError as exc:
|
|
358
|
+
raise SetupError(
|
|
359
|
+
f"updating [mcp_servers.{name}] would produce invalid TOML ({exc}); the table probably "
|
|
360
|
+
"uses multi-line values or a separate sub-table for a key setup manages. Not modified."
|
|
361
|
+
) from None
|
|
362
|
+
entry = after.get("mcp_servers", {}).get(name, {})
|
|
363
|
+
unexpected = [key for key in table.owned if key not in table.expected and key in entry]
|
|
364
|
+
if any(entry.get(key) != value for key, value in table.expected.items()) or unexpected:
|
|
365
|
+
raise SetupError(f"could not verify the updated [mcp_servers.{name}] table; not modified")
|
|
366
|
+
if without_table(before, name, table.owned) != without_table(after, name, table.owned):
|
|
367
|
+
raise SetupError("updating the Codex config would change other settings; not modified")
|
|
368
|
+
return updated
|
|
369
|
+
|
|
370
|
+
|
|
371
|
+
def update_codex_text(text: str, key: str, config: Path, mcp: Path) -> str:
|
|
372
|
+
return update_codex_table(text, bus_table(key, config, mcp))
|
|
373
|
+
|
|
374
|
+
|
|
375
|
+
def wire_codex(tables: list[CodexTable], path: Path | None = None) -> list[str]:
|
|
376
|
+
"""Update every table in one write, with one backup of the previous file."""
|
|
377
|
+
path = path or codex_config_path()
|
|
378
|
+
text = path.read_text() if path.exists() else ""
|
|
379
|
+
updated = text
|
|
380
|
+
for table in tables:
|
|
381
|
+
updated = update_codex_table(updated, table)
|
|
382
|
+
names = ", ".join(f"[mcp_servers.{table.name}]" for table in tables)
|
|
383
|
+
if updated == text:
|
|
384
|
+
return [f"{names} already current in {show(path)}"]
|
|
385
|
+
if path.exists():
|
|
386
|
+
backup = path.with_name(path.name + ".caura-bus-setup.bak")
|
|
387
|
+
shutil.copy2(path, backup)
|
|
388
|
+
os.chmod(backup, 0o600)
|
|
389
|
+
write_private(path, updated) # it now holds an agent key
|
|
390
|
+
return [
|
|
391
|
+
f"{names} written to {show(path)}" + (f" (backup {path.name}.caura-bus-setup.bak)" if text else "")
|
|
392
|
+
]
|
|
393
|
+
|
|
394
|
+
|
|
395
|
+
# --- 3b. memory MCP of the same server -----------------------------------
|
|
396
|
+
|
|
397
|
+
|
|
398
|
+
def memory_url(url: str) -> str:
|
|
399
|
+
return url.rstrip("/") + MEMORY_PATH
|
|
400
|
+
|
|
401
|
+
|
|
402
|
+
def host_slug(url: str) -> str:
|
|
403
|
+
netloc = urlsplit(url).netloc.lower()
|
|
404
|
+
return re.sub(r"-{2,}", "-", re.sub(r"[^a-z0-9]+", "-", netloc)).strip("-") or "host"
|
|
405
|
+
|
|
406
|
+
|
|
407
|
+
def codex_memory_name(url: str) -> str:
|
|
408
|
+
return f"{MEMORY_SERVER_NAME}-{host_slug(url)}"
|
|
409
|
+
|
|
410
|
+
|
|
411
|
+
def memory_like(name: str) -> bool:
|
|
412
|
+
lowered = name.lower()
|
|
413
|
+
return any(lowered == base or lowered.startswith(base + "-") for base in MEMORY_LIKE_NAMES)
|
|
414
|
+
|
|
415
|
+
|
|
416
|
+
def entry_target(entry: Any) -> str | None:
|
|
417
|
+
"""The host an MCP entry talks to, ``""`` for a command we cannot read, None if not an entry."""
|
|
418
|
+
if not isinstance(entry, dict):
|
|
419
|
+
return None
|
|
420
|
+
target = entry.get("url")
|
|
421
|
+
if isinstance(target, str) and target:
|
|
422
|
+
return (urlsplit(target).hostname or target).lower()
|
|
423
|
+
return "" if entry.get("command") else None
|
|
424
|
+
|
|
425
|
+
|
|
426
|
+
def other_memory_entries(servers: Any, ours: str, url: str) -> list[str]:
|
|
427
|
+
"""Describe memory-like entries other than ``ours`` that do not point at this server."""
|
|
428
|
+
if not isinstance(servers, dict):
|
|
429
|
+
return []
|
|
430
|
+
host = (urlsplit(url).hostname or "").lower()
|
|
431
|
+
found = []
|
|
432
|
+
for name, entry in servers.items():
|
|
433
|
+
if name == ours or name == SERVER_NAME or not memory_like(str(name)):
|
|
434
|
+
continue
|
|
435
|
+
target = entry_target(entry)
|
|
436
|
+
if target is None or target == host:
|
|
437
|
+
continue
|
|
438
|
+
if target == "":
|
|
439
|
+
if str(name).lower().startswith(MEMORY_SERVER_NAME + "-"):
|
|
440
|
+
continue # caura-bus and friends: local commands, not memory servers
|
|
441
|
+
found.append(f"{name!r} (a local command; cannot tell which server it uses)")
|
|
442
|
+
else:
|
|
443
|
+
found.append(f"{name!r} -> {target}")
|
|
444
|
+
return found
|
|
445
|
+
|
|
446
|
+
|
|
447
|
+
def claude_config_file() -> Path:
|
|
448
|
+
base = os.environ.get("CLAUDE_CONFIG_DIR", "").strip()
|
|
449
|
+
return (Path(base) if base else Path.home()) / ".claude.json"
|
|
450
|
+
|
|
451
|
+
|
|
452
|
+
def read_json(path: Path) -> dict:
|
|
453
|
+
try:
|
|
454
|
+
data = json.loads(path.read_text())
|
|
455
|
+
except (OSError, ValueError):
|
|
456
|
+
return {}
|
|
457
|
+
return data if isinstance(data, dict) else {}
|
|
458
|
+
|
|
459
|
+
|
|
460
|
+
def claude_memory_warnings(directory: Path, url: str) -> list[str]:
|
|
461
|
+
"""Memory-like Claude Code entries that would still be loaded here and point elsewhere."""
|
|
462
|
+
warnings = []
|
|
463
|
+
user = other_memory_entries(read_json(claude_config_file()).get("mcpServers"), "", url)
|
|
464
|
+
for item in user:
|
|
465
|
+
overridden = item.startswith(repr(MEMORY_SERVER_NAME) + " ")
|
|
466
|
+
warnings.append(
|
|
467
|
+
f"user-scope MCP server {item}"
|
|
468
|
+
+ (
|
|
469
|
+
f" is overridden in this project by the local {MEMORY_SERVER_NAME!r} entry"
|
|
470
|
+
if overridden
|
|
471
|
+
else ": still loaded in this project, so the agent may save memories there"
|
|
472
|
+
)
|
|
473
|
+
)
|
|
474
|
+
project = other_memory_entries(read_json(directory / ".mcp.json").get("mcpServers"), "", url)
|
|
475
|
+
for item in project:
|
|
476
|
+
overridden = item.startswith(repr(MEMORY_SERVER_NAME) + " ")
|
|
477
|
+
warnings.append(
|
|
478
|
+
f"project .mcp.json server {item}"
|
|
479
|
+
+ (" is overridden by the local entry" if overridden else ": the agent may save memories there")
|
|
480
|
+
)
|
|
481
|
+
return warnings
|
|
482
|
+
|
|
483
|
+
|
|
484
|
+
def codex_memory_warnings(text: str, url: str) -> list[str]:
|
|
485
|
+
try:
|
|
486
|
+
servers = tomllib.loads(text).get("mcp_servers")
|
|
487
|
+
except tomllib.TOMLDecodeError:
|
|
488
|
+
return []
|
|
489
|
+
return [
|
|
490
|
+
f"Codex MCP server {item}: Codex loads it too, so the agent may save memories there"
|
|
491
|
+
for item in other_memory_entries(servers, codex_memory_name(url), url)
|
|
492
|
+
]
|
|
493
|
+
|
|
494
|
+
|
|
495
|
+
def claude_memory_argv(claude: str, url: str, key: str) -> list[str]:
|
|
496
|
+
# --header is variadic, so it goes after the name and URL.
|
|
497
|
+
return [
|
|
498
|
+
claude,
|
|
499
|
+
"mcp",
|
|
500
|
+
"add",
|
|
501
|
+
"--scope",
|
|
502
|
+
"local",
|
|
503
|
+
"--transport",
|
|
504
|
+
"http",
|
|
505
|
+
MEMORY_SERVER_NAME,
|
|
506
|
+
memory_url(url),
|
|
507
|
+
"--header",
|
|
508
|
+
f"{MEMORY_HEADER}: {key}",
|
|
509
|
+
]
|
|
510
|
+
|
|
511
|
+
|
|
512
|
+
def claude_memory_manual_command(directory: Path, url: str) -> str:
|
|
513
|
+
command = shlex.join(claude_memory_argv("claude", url, "KEYPLACEHOLDER")).replace(
|
|
514
|
+
f"'{MEMORY_HEADER}: KEYPLACEHOLDER'",
|
|
515
|
+
f'"{MEMORY_HEADER}: ${{CAURA_API_KEY:?export your agent key first}}"',
|
|
516
|
+
)
|
|
517
|
+
return f"cd {shlex.quote(str(directory))} && {command}"
|
|
518
|
+
|
|
519
|
+
|
|
520
|
+
def wire_claude_memory(directory: Path, url: str, key: str) -> str:
|
|
521
|
+
claude = shutil.which("claude")
|
|
522
|
+
if not claude:
|
|
523
|
+
raise SetupError(
|
|
524
|
+
"`claude` is not on PATH. Run this yourself (with CAURA_API_KEY exported):\n "
|
|
525
|
+
+ claude_memory_manual_command(directory, url)
|
|
526
|
+
)
|
|
527
|
+
subprocess.run(
|
|
528
|
+
[claude, "mcp", "remove", "--scope", "local", MEMORY_SERVER_NAME],
|
|
529
|
+
cwd=directory,
|
|
530
|
+
capture_output=True,
|
|
531
|
+
text=True,
|
|
532
|
+
check=False,
|
|
533
|
+
)
|
|
534
|
+
result = subprocess.run(
|
|
535
|
+
claude_memory_argv(claude, url, key), cwd=directory, capture_output=True, text=True, check=False
|
|
536
|
+
)
|
|
537
|
+
if result.returncode != 0:
|
|
538
|
+
detail = scrub((result.stderr or result.stdout).strip(), key)
|
|
539
|
+
raise SetupError(f"`claude mcp add` for the memory server failed ({result.returncode}): {detail}")
|
|
540
|
+
return (
|
|
541
|
+
f"claude mcp add --scope local --transport http {MEMORY_SERVER_NAME} {memory_url(url)} "
|
|
542
|
+
f"(project {show(directory)}; the key is stored in {show(claude_config_file())})"
|
|
543
|
+
)
|
|
544
|
+
|
|
545
|
+
|
|
546
|
+
# --- 4. peer instructions -------------------------------------------------
|
|
547
|
+
|
|
548
|
+
|
|
549
|
+
def instruction_block() -> str:
|
|
550
|
+
return f"{MARK_HEADER}\n{peer_template().rstrip()}\n{MARK_END}\n"
|
|
551
|
+
|
|
552
|
+
|
|
553
|
+
def merge_instructions(existing: str | None) -> tuple[str, str]:
|
|
554
|
+
block = instruction_block()
|
|
555
|
+
if existing is None:
|
|
556
|
+
return block, "created"
|
|
557
|
+
start = existing.find(MARK_START)
|
|
558
|
+
if start != -1:
|
|
559
|
+
end = existing.find(MARK_END, start)
|
|
560
|
+
if end == -1:
|
|
561
|
+
raise SetupError(f"found the {MARK_START} marker without its end marker; fix the file by hand")
|
|
562
|
+
end += len(MARK_END)
|
|
563
|
+
if existing[end : end + 1] == "\n":
|
|
564
|
+
end += 1
|
|
565
|
+
updated = existing[:start] + block + existing[end:]
|
|
566
|
+
return updated, "unchanged" if updated == existing else "updated"
|
|
567
|
+
if not existing or existing.endswith("\n\n"):
|
|
568
|
+
separator = ""
|
|
569
|
+
else:
|
|
570
|
+
separator = "\n" if existing.endswith("\n") else "\n\n"
|
|
571
|
+
return existing + separator + block, "appended"
|
|
572
|
+
|
|
573
|
+
|
|
574
|
+
def write_instructions(path: Path) -> str:
|
|
575
|
+
existing = path.read_text() if path.exists() else None
|
|
576
|
+
updated, action = merge_instructions(existing)
|
|
577
|
+
if action != "unchanged":
|
|
578
|
+
temporary = path.with_name(path.name + ".tmp")
|
|
579
|
+
temporary.write_text(updated)
|
|
580
|
+
if path.exists():
|
|
581
|
+
os.chmod(temporary, path.stat().st_mode & 0o777)
|
|
582
|
+
temporary.replace(path)
|
|
583
|
+
return action
|
|
584
|
+
|
|
585
|
+
|
|
586
|
+
# --- 5. wake-ups (opt-in) -----------------------------------------------
|
|
587
|
+
|
|
588
|
+
|
|
589
|
+
def key_file_path(config: Path) -> Path:
|
|
590
|
+
return config.with_suffix(".key")
|
|
591
|
+
|
|
592
|
+
|
|
593
|
+
def codex_waker_command(directory: Path, config: Path, key_file: Path) -> str:
|
|
594
|
+
return shlex.join(
|
|
595
|
+
[
|
|
596
|
+
"caura-bus",
|
|
597
|
+
"wake",
|
|
598
|
+
"--runtime",
|
|
599
|
+
"codex",
|
|
600
|
+
"--thread",
|
|
601
|
+
"latest",
|
|
602
|
+
"--dir",
|
|
603
|
+
str(directory),
|
|
604
|
+
"--config",
|
|
605
|
+
str(config),
|
|
606
|
+
"--key-file",
|
|
607
|
+
str(key_file),
|
|
608
|
+
]
|
|
609
|
+
)
|
|
610
|
+
|
|
611
|
+
|
|
612
|
+
def install_wake(options, runtime, directory, config_path, key, echo) -> str | None:
|
|
613
|
+
"""Store the key for hooks/wakers (0600) and install Claude hooks or print the Codex waker.
|
|
614
|
+
|
|
615
|
+
Hooks and wakers are started by the runtime, not by the shell that holds
|
|
616
|
+
CAURA_API_KEY, so they read the key from a private file next to the config.
|
|
617
|
+
"""
|
|
618
|
+
from .hooks import install_hooks
|
|
619
|
+
|
|
620
|
+
key_file = key_file_path(config_path)
|
|
621
|
+
prefix = "[dry-run] would " if options.dry_run else ""
|
|
622
|
+
if options.dry_run:
|
|
623
|
+
echo(f" {prefix}write the agent key to {show(key_file)} (mode 600) for wake-ups")
|
|
624
|
+
else:
|
|
625
|
+
write_private(key_file, key + "\n")
|
|
626
|
+
echo(f" [ok] agent key for wake-ups {show(key_file)} (mode 600)")
|
|
627
|
+
if runtime == "codex":
|
|
628
|
+
command = codex_waker_command(directory, config_path, key_file)
|
|
629
|
+
echo(f" {prefix if options.dry_run else '[ok] '}Codex wake-ups: run `{command}` while Codex is open")
|
|
630
|
+
return command
|
|
631
|
+
if options.dry_run:
|
|
632
|
+
echo(f" {prefix}install Claude Code wake hooks in {show(directory / '.claude/settings.local.json')}")
|
|
633
|
+
return None
|
|
634
|
+
try:
|
|
635
|
+
result = install_hooks(project=directory, config=config_path, key_file=key_file)
|
|
636
|
+
except (RuntimeError, ValueError, OSError) as exc:
|
|
637
|
+
echo(f" [!!] wake hooks not installed: {exc}")
|
|
638
|
+
return None
|
|
639
|
+
echo(f" [ok] wake hooks {show(result['path'])} (SessionStart/Stop listener, UserPromptSubmit check)")
|
|
640
|
+
return None
|
|
641
|
+
|
|
642
|
+
|
|
643
|
+
# --- orchestration --------------------------------------------------------
|
|
644
|
+
|
|
645
|
+
|
|
646
|
+
@dataclass
|
|
647
|
+
class Options:
|
|
648
|
+
runtime: str
|
|
649
|
+
url: str
|
|
650
|
+
key: str
|
|
651
|
+
directory: Path
|
|
652
|
+
agent_id: str | None = None
|
|
653
|
+
description: str | None = None
|
|
654
|
+
config: Path | None = None
|
|
655
|
+
dry_run: bool = False
|
|
656
|
+
hooks: bool = False
|
|
657
|
+
codex_config: Path | None = None
|
|
658
|
+
# Also register this server's memory MCP (`caura`), so durable memories land here.
|
|
659
|
+
memory: bool = True
|
|
660
|
+
transport: httpx.AsyncBaseTransport | None = field(default=None, repr=False)
|
|
661
|
+
# `join` prints its own next steps; it reads back what setup wrote.
|
|
662
|
+
next_steps: bool = True
|
|
663
|
+
config_path: Path | None = field(default=None, init=False)
|
|
664
|
+
waker: str | None = field(default=None, init=False)
|
|
665
|
+
|
|
666
|
+
|
|
667
|
+
def run_setup(options: Options, echo: Callable[[str], None], run_async) -> int:
|
|
668
|
+
runtime = RUNTIMES.get(options.runtime)
|
|
669
|
+
if runtime is None:
|
|
670
|
+
raise SetupError("--runtime must be claude or codex")
|
|
671
|
+
key = options.key.strip()
|
|
672
|
+
if not key:
|
|
673
|
+
raise SetupError("an agent key is required (--key or CAURA_API_KEY)")
|
|
674
|
+
url, insecure = check_url(options.url)
|
|
675
|
+
directory = options.directory.expanduser().resolve()
|
|
676
|
+
if not directory.is_dir():
|
|
677
|
+
raise SetupError(f"--dir {directory} is not a directory")
|
|
678
|
+
prefix = "[dry-run] would " if options.dry_run else ""
|
|
679
|
+
echo(f"Connecting {RUNTIME_NAMES[runtime]} in {show(directory)} to Caura at {url}")
|
|
680
|
+
|
|
681
|
+
try:
|
|
682
|
+
identity = run_async(verify(url, insecure, key, options.transport))
|
|
683
|
+
except PlatformError as exc:
|
|
684
|
+
if exc.status in {401, 403}:
|
|
685
|
+
raise SetupError(
|
|
686
|
+
f"the server rejected key {mask(key)} ({exc.status}); check it and --url"
|
|
687
|
+
) from None
|
|
688
|
+
raise SetupError(scrub(str(exc), key)) from None
|
|
689
|
+
except httpx.HTTPError as exc:
|
|
690
|
+
raise SetupError(f"cannot reach {url}: {scrub(str(exc), key) or type(exc).__name__}") from None
|
|
691
|
+
agent_id, tenant_id = identity["agent_id"], identity["tenant_id"]
|
|
692
|
+
if options.agent_id and options.agent_id != agent_id:
|
|
693
|
+
raise SetupError(
|
|
694
|
+
f"key {mask(key)} belongs to agent {agent_id!r}, not {options.agent_id!r}; nothing was changed"
|
|
695
|
+
)
|
|
696
|
+
echo(f" [ok] key {mask(key)} verified: agent {agent_id}, tenant {tenant_id}")
|
|
697
|
+
|
|
698
|
+
config_path = (options.config or default_config_path(url, tenant_id, agent_id)).expanduser().absolute()
|
|
699
|
+
options.config_path = config_path
|
|
700
|
+
text = render_config(url, insecure, identity, config_path)
|
|
701
|
+
AgentConfig.model_validate(tomllib.loads(text))
|
|
702
|
+
unchanged = config_path.is_file() and config_path.read_text() == text
|
|
703
|
+
if options.dry_run:
|
|
704
|
+
echo(f" {prefix}write agent config {show(config_path)} (mode 600)")
|
|
705
|
+
else:
|
|
706
|
+
if not unchanged:
|
|
707
|
+
write_private(config_path, text)
|
|
708
|
+
os.chmod(config_path, 0o600)
|
|
709
|
+
load_config(config_path)
|
|
710
|
+
echo(f" [ok] agent config {show(config_path)} (mode 600{', unchanged' if unchanged else ''})")
|
|
711
|
+
|
|
712
|
+
mcp = find_mcp_binary()
|
|
713
|
+
if mcp is None:
|
|
714
|
+
raise SetupError(
|
|
715
|
+
"caura-bus-mcp is not installed next to caura-bus or on PATH; install it "
|
|
716
|
+
"(it ships with caura-bus-cli) and re-run setup"
|
|
717
|
+
)
|
|
718
|
+
if runtime == "claude":
|
|
719
|
+
if options.dry_run:
|
|
720
|
+
echo(f" {prefix}run in {directory}:")
|
|
721
|
+
echo(" " + shlex.join(claude_add_argv("claude", mask(key), config_path, mcp)))
|
|
722
|
+
if options.memory:
|
|
723
|
+
echo(" " + shlex.join(claude_memory_argv("claude", url, mask(key))))
|
|
724
|
+
else:
|
|
725
|
+
try:
|
|
726
|
+
echo(" [ok] MCP server " + wire_claude(directory, key, config_path, mcp))
|
|
727
|
+
if options.memory:
|
|
728
|
+
echo(" [ok] memory MCP server " + wire_claude_memory(directory, url, key))
|
|
729
|
+
except SetupError as exc:
|
|
730
|
+
echo(f" [!!] {exc}")
|
|
731
|
+
return finish(options, runtime, directory, config_path, echo, run_async, key, failed=True)
|
|
732
|
+
if options.memory:
|
|
733
|
+
for warning in claude_memory_warnings(directory, url):
|
|
734
|
+
echo(f" [!!] {warning}")
|
|
735
|
+
echo(
|
|
736
|
+
f" check which servers this project loads: cd {shell_path(directory)} && claude mcp list"
|
|
737
|
+
)
|
|
738
|
+
else:
|
|
739
|
+
codex_path = (options.codex_config or codex_config_path()).expanduser()
|
|
740
|
+
tables = [bus_table(key, config_path, mcp)]
|
|
741
|
+
if options.memory:
|
|
742
|
+
tables.append(memory_table(url, key))
|
|
743
|
+
current = codex_path.read_text() if codex_path.exists() else ""
|
|
744
|
+
if options.dry_run:
|
|
745
|
+
planned = current
|
|
746
|
+
for table in tables:
|
|
747
|
+
planned = update_codex_table(planned, table)
|
|
748
|
+
echo(
|
|
749
|
+
f" {prefix}add/update [mcp_servers.{SERVER_NAME}] in {show(codex_path)} (backup kept, mode 600)"
|
|
750
|
+
)
|
|
751
|
+
echo(
|
|
752
|
+
f" command = {mcp}; env CAURA_API_KEY={mask(key)}, CAURA_BUS_AGENT_CONFIG={config_path}"
|
|
753
|
+
)
|
|
754
|
+
if options.memory:
|
|
755
|
+
echo(f" {prefix}add/update [mcp_servers.{codex_memory_name(url)}] in {show(codex_path)}")
|
|
756
|
+
echo(f" url = {memory_url(url)}; http_headers {MEMORY_HEADER}={mask(key)}")
|
|
757
|
+
else:
|
|
758
|
+
for line in wire_codex(tables, codex_path):
|
|
759
|
+
echo(" [ok] MCP server" + ("s " if options.memory else " ") + line)
|
|
760
|
+
if options.memory:
|
|
761
|
+
for warning in codex_memory_warnings(current, url):
|
|
762
|
+
echo(f" [!!] {warning}")
|
|
763
|
+
echo(" check which servers Codex loads: codex mcp list")
|
|
764
|
+
return finish(options, runtime, directory, config_path, echo, run_async, key, failed=False)
|
|
765
|
+
|
|
766
|
+
|
|
767
|
+
def finish(options, runtime, directory, config_path, echo, run_async, key, *, failed) -> int:
|
|
768
|
+
prefix = "[dry-run] would " if options.dry_run else ""
|
|
769
|
+
instructions = directory / INSTRUCTION_FILES[runtime]
|
|
770
|
+
if options.dry_run:
|
|
771
|
+
_, action = merge_instructions(instructions.read_text() if instructions.exists() else None)
|
|
772
|
+
verb = {"created": "create", "appended": "append to", "updated": "update", "unchanged": "keep"}[
|
|
773
|
+
action
|
|
774
|
+
]
|
|
775
|
+
echo(f" {prefix}{verb} peer instructions in {show(instructions)}")
|
|
776
|
+
else:
|
|
777
|
+
echo(f" [ok] peer instructions {show(instructions)} ({write_instructions(instructions)})")
|
|
778
|
+
|
|
779
|
+
if options.description is not None:
|
|
780
|
+
if options.dry_run:
|
|
781
|
+
echo(f" {prefix}register description ({len(options.description)} chars)")
|
|
782
|
+
else:
|
|
783
|
+
try:
|
|
784
|
+
run_async(describe(load_config(config_path), key, options.description, options.transport))
|
|
785
|
+
echo(" [ok] description registered")
|
|
786
|
+
except PlatformError as exc:
|
|
787
|
+
note = "this server does not support descriptions yet" if exc.status == 404 else str(exc)
|
|
788
|
+
echo(f" [!!] description not registered: {scrub(note, key)}")
|
|
789
|
+
except ValueError as exc:
|
|
790
|
+
echo(f" [!!] description not registered: {exc}")
|
|
791
|
+
|
|
792
|
+
waker = None
|
|
793
|
+
if options.hooks:
|
|
794
|
+
waker = install_wake(options, runtime, directory, config_path, key, echo)
|
|
795
|
+
options.waker = waker
|
|
796
|
+
|
|
797
|
+
if options.dry_run:
|
|
798
|
+
echo("Dry run: nothing was written.")
|
|
799
|
+
return 0
|
|
800
|
+
if not options.next_steps:
|
|
801
|
+
return 1 if failed else 0
|
|
802
|
+
start = f"cd {shell_path(directory)} && {'claude' if runtime == 'claude' else 'codex'}"
|
|
803
|
+
echo("")
|
|
804
|
+
echo("Next steps:" if not failed else "Finish the MCP step above, then:")
|
|
805
|
+
if runtime == "claude":
|
|
806
|
+
echo(f" 1. Start Claude Code in the project: {start}")
|
|
807
|
+
else:
|
|
808
|
+
echo(f" 1. Start (or restart) Codex in the project: {start}")
|
|
809
|
+
if waker:
|
|
810
|
+
echo(f" In another terminal, keep the wake listener running: {waker}")
|
|
811
|
+
echo(' 2. Try: "Use the caura-bus peer tool: discover available peers and tell me who they are."')
|
|
812
|
+
if options.memory:
|
|
813
|
+
listing = "claude mcp list" if runtime == "claude" else "codex mcp list"
|
|
814
|
+
echo(f" Memory: `{listing}` should show this server's memory MCP ({memory_url(options.url)}).")
|
|
815
|
+
echo(
|
|
816
|
+
" 3. Check the connection any time: CAURA_API_KEY=<your key> caura-bus doctor "
|
|
817
|
+
f"--config {shell_path(config_path)}"
|
|
818
|
+
)
|
|
819
|
+
return 1 if failed else 0
|