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/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