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/join.py
ADDED
|
@@ -0,0 +1,438 @@
|
|
|
1
|
+
"""`caura-bus join`: sign in as the human, mint an agent key, and run setup in one step.
|
|
2
|
+
|
|
3
|
+
The human signs in the way the dashboard does (email and password, giving a
|
|
4
|
+
session cookie and a CSRF token). The session is used only for this command and
|
|
5
|
+
signed out at the end. Nothing from it is stored, and the password is never
|
|
6
|
+
written anywhere. The new agent key goes through the normal ``setup`` path,
|
|
7
|
+
which verifies it against the server before writing anything.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import hashlib
|
|
13
|
+
import os
|
|
14
|
+
import re
|
|
15
|
+
import secrets
|
|
16
|
+
import socket
|
|
17
|
+
import subprocess
|
|
18
|
+
from collections.abc import Callable, Mapping
|
|
19
|
+
from dataclasses import dataclass, field
|
|
20
|
+
from pathlib import Path
|
|
21
|
+
from typing import Any
|
|
22
|
+
|
|
23
|
+
import httpx
|
|
24
|
+
|
|
25
|
+
from .setup import RUNTIME_NAMES, Options, SetupError, check_url, run_setup, shell_path, show
|
|
26
|
+
|
|
27
|
+
LABEL_PREFIX = "caura-bus join"
|
|
28
|
+
NAME_MAX = 64
|
|
29
|
+
CSRF_COOKIE = "csrf_token"
|
|
30
|
+
CSRF_HEADER = "X-CSRF-Token"
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class JoinError(RuntimeError):
|
|
34
|
+
"""A step failed; the message is safe to print (no password, no key)."""
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
# --- runtime detection ------------------------------------------------------
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def _ancestor_runtime(pid: int | None = None, depth: int = 25) -> str | None:
|
|
41
|
+
"""The nearest ancestor process that is Claude Code or Codex, if any."""
|
|
42
|
+
pid = pid or os.getppid()
|
|
43
|
+
for _ in range(depth):
|
|
44
|
+
if pid <= 1:
|
|
45
|
+
return None
|
|
46
|
+
try:
|
|
47
|
+
out = subprocess.run(
|
|
48
|
+
["ps", "-o", "ppid=,comm=", "-p", str(pid)], capture_output=True, text=True, check=False
|
|
49
|
+
).stdout.strip()
|
|
50
|
+
except OSError:
|
|
51
|
+
return None
|
|
52
|
+
if not out:
|
|
53
|
+
return None
|
|
54
|
+
parent, _, command = out.partition(" ")
|
|
55
|
+
name = Path(command.strip()).name.lower()
|
|
56
|
+
if name.startswith("codex"):
|
|
57
|
+
return "codex"
|
|
58
|
+
if name.startswith("claude"):
|
|
59
|
+
return "claude"
|
|
60
|
+
try:
|
|
61
|
+
pid = int(parent)
|
|
62
|
+
except ValueError:
|
|
63
|
+
return None
|
|
64
|
+
return None
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def detect_runtime(
|
|
68
|
+
requested: str,
|
|
69
|
+
env: Mapping[str, str] | None = None,
|
|
70
|
+
ancestor: Callable[[], str | None] = _ancestor_runtime,
|
|
71
|
+
) -> str:
|
|
72
|
+
"""Resolve --runtime. ``auto`` reads the markers each runtime sets in its shell tool.
|
|
73
|
+
|
|
74
|
+
Claude Code sets ``CLAUDECODE=1``. Codex sets ``CODEX_THREAD_ID``,
|
|
75
|
+
``CODEX_SESSION_ID`` and ``CODEX_SANDBOX`` in the commands it runs. Both
|
|
76
|
+
are inherited by child processes, so when both are present (one runtime
|
|
77
|
+
started from inside the other) the nearest ancestor process decides.
|
|
78
|
+
"""
|
|
79
|
+
requested = (requested or "auto").strip().lower()
|
|
80
|
+
if requested in {"claude", "claude-code"}:
|
|
81
|
+
return "claude"
|
|
82
|
+
if requested == "codex":
|
|
83
|
+
return "codex"
|
|
84
|
+
if requested != "auto":
|
|
85
|
+
raise JoinError("--runtime must be auto, claude or codex")
|
|
86
|
+
env = os.environ if env is None else env
|
|
87
|
+
claude = env.get("CLAUDECODE") == "1"
|
|
88
|
+
codex = any(env.get(name) for name in ("CODEX_THREAD_ID", "CODEX_SESSION_ID", "CODEX_SANDBOX"))
|
|
89
|
+
if claude and codex:
|
|
90
|
+
nearest = ancestor()
|
|
91
|
+
if nearest:
|
|
92
|
+
return nearest
|
|
93
|
+
raise JoinError(
|
|
94
|
+
"both Claude Code and Codex markers are set in this environment; pass --runtime claude or codex"
|
|
95
|
+
)
|
|
96
|
+
if claude:
|
|
97
|
+
return "claude"
|
|
98
|
+
if codex:
|
|
99
|
+
return "codex"
|
|
100
|
+
raise JoinError(
|
|
101
|
+
"could not detect the runtime: run this from inside a Claude Code or Codex session, "
|
|
102
|
+
"or pass --runtime claude or --runtime codex"
|
|
103
|
+
)
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
# --- naming -----------------------------------------------------------------
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def sanitize_name(value: str) -> str:
|
|
110
|
+
name = re.sub(r"[^a-z0-9-]+", "-", value.strip().lower())
|
|
111
|
+
name = re.sub(r"-{2,}", "-", name).strip("-")
|
|
112
|
+
return name[:NAME_MAX].strip("-")
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def default_name(runtime: str, directory: Path) -> str:
|
|
116
|
+
stem = sanitize_name(directory.name)[:24].strip("-") or "agent"
|
|
117
|
+
return sanitize_name(f"{runtime}-{stem}-{secrets.token_hex(2)}")
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def dir_tag(directory: Path) -> str:
|
|
121
|
+
"""A stable, non-reversible tag for this directory on this machine."""
|
|
122
|
+
return hashlib.sha256(f"{socket.gethostname()}:{directory}".encode()).hexdigest()[:12]
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def make_label(runtime: str, directory: Path, tag: str) -> str:
|
|
126
|
+
host = socket.gethostname().split(".")[0][:40] or "host"
|
|
127
|
+
label = f"{LABEL_PREFIX}: {runtime} in {directory.name or '/'} on {host}"
|
|
128
|
+
suffix = f" [dir {tag}]"
|
|
129
|
+
return label[: 200 - len(suffix)] + suffix
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
# --- dashboard session ------------------------------------------------------
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def _detail(response: httpx.Response) -> str:
|
|
136
|
+
try:
|
|
137
|
+
body = response.json()
|
|
138
|
+
except ValueError:
|
|
139
|
+
return f"HTTP {response.status_code}"
|
|
140
|
+
detail = body.get("detail") if isinstance(body, dict) else None
|
|
141
|
+
if isinstance(detail, list | dict):
|
|
142
|
+
detail = str(detail)[:300]
|
|
143
|
+
return f"{detail} (HTTP {response.status_code})" if detail else f"HTTP {response.status_code}"
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
class Session:
|
|
147
|
+
"""The human's dashboard session for the length of one command."""
|
|
148
|
+
|
|
149
|
+
def __init__(self, url: str, transport: httpx.BaseTransport | None = None):
|
|
150
|
+
self.http = httpx.Client(base_url=url, timeout=30, follow_redirects=False, transport=transport)
|
|
151
|
+
self.headers: dict[str, str] = {}
|
|
152
|
+
self.org_id = ""
|
|
153
|
+
self.user_id = ""
|
|
154
|
+
self.tenant_id = ""
|
|
155
|
+
|
|
156
|
+
def _call(self, method: str, path: str, **kwargs: Any) -> httpx.Response:
|
|
157
|
+
try:
|
|
158
|
+
return self.http.request(method, path, **kwargs)
|
|
159
|
+
except httpx.HTTPError as exc:
|
|
160
|
+
raise JoinError(f"cannot reach {self.http.base_url}: {type(exc).__name__}") from None
|
|
161
|
+
|
|
162
|
+
def login(self, email: str, password: str, totp: Callable[[], str] | None = None) -> dict:
|
|
163
|
+
payload: dict[str, str] = {"email": email, "password": password}
|
|
164
|
+
response = self._call("POST", "/api/auth/user/login", json=payload)
|
|
165
|
+
body = self._login_body(response)
|
|
166
|
+
if body.get("totp_required"):
|
|
167
|
+
if totp is None:
|
|
168
|
+
raise JoinError(
|
|
169
|
+
"this account uses two-factor sign-in; set CAURA_TOTP_CODE or run in a terminal"
|
|
170
|
+
)
|
|
171
|
+
payload["totp_code"] = totp()
|
|
172
|
+
body = self._login_body(self._call("POST", "/api/auth/user/login", json=payload))
|
|
173
|
+
if not body.get("ok"):
|
|
174
|
+
raise JoinError("sign-in did not complete")
|
|
175
|
+
if body.get("must_change_password"):
|
|
176
|
+
raise JoinError("this account must change its password first; sign in to the dashboard once")
|
|
177
|
+
csrf = self.http.cookies.get(CSRF_COOKIE)
|
|
178
|
+
if not csrf or not body.get("org_id") or not body.get("user_id"):
|
|
179
|
+
raise JoinError("the server did not return a dashboard session; is --url a Caura gateway?")
|
|
180
|
+
self.headers = {CSRF_HEADER: csrf}
|
|
181
|
+
self.org_id, self.user_id = str(body["org_id"]), str(body["user_id"])
|
|
182
|
+
self.tenant_id = str(body.get("tenant_id") or "")
|
|
183
|
+
return body
|
|
184
|
+
|
|
185
|
+
@staticmethod
|
|
186
|
+
def _login_body(response: httpx.Response) -> dict:
|
|
187
|
+
if response.status_code == 401:
|
|
188
|
+
raise JoinError("sign-in failed: wrong email, password or code")
|
|
189
|
+
if response.status_code == 429:
|
|
190
|
+
raise JoinError("sign-in refused: the account is locked or rate limited; try again later")
|
|
191
|
+
if response.status_code == 404:
|
|
192
|
+
raise JoinError("no sign-in endpoint at this URL; is --url a Caura gateway?")
|
|
193
|
+
if response.status_code != 200:
|
|
194
|
+
raise JoinError(f"sign-in failed: {_detail(response)}")
|
|
195
|
+
try:
|
|
196
|
+
body = response.json()
|
|
197
|
+
except ValueError:
|
|
198
|
+
raise JoinError("sign-in returned a non-JSON response; is --url a Caura gateway?") from None
|
|
199
|
+
return body if isinstance(body, dict) else {}
|
|
200
|
+
|
|
201
|
+
@property
|
|
202
|
+
def credentials_path(self) -> str:
|
|
203
|
+
return f"/api/v1/admin/orgs/{self.org_id}/api-credentials"
|
|
204
|
+
|
|
205
|
+
def tenant_options(self) -> list[str] | None:
|
|
206
|
+
response = self._call("GET", f"{self.credentials_path}/tenant-options")
|
|
207
|
+
if response.status_code != 200:
|
|
208
|
+
return None
|
|
209
|
+
return [str(item.get("tenant_id")) for item in response.json() if isinstance(item, dict)]
|
|
210
|
+
|
|
211
|
+
def agent_keys(self, tenant_id: str) -> list[dict]:
|
|
212
|
+
response = self._call(
|
|
213
|
+
"GET",
|
|
214
|
+
self.credentials_path,
|
|
215
|
+
params={"kind": "agent_key", "home_tenant_id": tenant_id, "include_revoked": "true"},
|
|
216
|
+
)
|
|
217
|
+
if response.status_code == 403:
|
|
218
|
+
raise JoinError(
|
|
219
|
+
"your account cannot manage API credentials in this organization "
|
|
220
|
+
"(needs the org.api_credentials.manage permission); ask an org admin"
|
|
221
|
+
)
|
|
222
|
+
if response.status_code != 200:
|
|
223
|
+
raise JoinError(f"listing agent keys failed: {_detail(response)}")
|
|
224
|
+
return [row for row in response.json().get("items", []) if row.get("home_tenant_id") == tenant_id]
|
|
225
|
+
|
|
226
|
+
def mint(self, tenant_id: str, agent_id: str, label: str) -> dict:
|
|
227
|
+
response = self._call(
|
|
228
|
+
"POST",
|
|
229
|
+
self.credentials_path,
|
|
230
|
+
headers=self.headers,
|
|
231
|
+
json={
|
|
232
|
+
"kind": "agent_key",
|
|
233
|
+
"home_tenant_id": tenant_id,
|
|
234
|
+
"home_agent_id": agent_id,
|
|
235
|
+
"capabilities": ["read", "write"],
|
|
236
|
+
"label": label,
|
|
237
|
+
},
|
|
238
|
+
)
|
|
239
|
+
if response.status_code != 201:
|
|
240
|
+
raise JoinError(f"minting the agent key failed: {_detail(response)}")
|
|
241
|
+
body = response.json()
|
|
242
|
+
if not body.get("raw_key") or not body.get("id"):
|
|
243
|
+
raise JoinError("the server did not return the new agent key")
|
|
244
|
+
return body
|
|
245
|
+
|
|
246
|
+
def revoke(self, credential_id: str) -> bool:
|
|
247
|
+
response = self._call("DELETE", f"{self.credentials_path}/{credential_id}", headers=self.headers)
|
|
248
|
+
return response.status_code in {200, 204}
|
|
249
|
+
|
|
250
|
+
def close(self) -> None:
|
|
251
|
+
try:
|
|
252
|
+
if self.headers:
|
|
253
|
+
self.http.post("/api/auth/user/logout", headers=self.headers)
|
|
254
|
+
except httpx.HTTPError:
|
|
255
|
+
pass
|
|
256
|
+
finally:
|
|
257
|
+
self.http.close()
|
|
258
|
+
|
|
259
|
+
|
|
260
|
+
# --- orchestration ----------------------------------------------------------
|
|
261
|
+
|
|
262
|
+
|
|
263
|
+
@dataclass
|
|
264
|
+
class JoinOptions:
|
|
265
|
+
url: str
|
|
266
|
+
email: str
|
|
267
|
+
password: str = field(repr=False)
|
|
268
|
+
directory: Path
|
|
269
|
+
runtime: str = "auto"
|
|
270
|
+
name: str | None = None
|
|
271
|
+
description: str | None = None
|
|
272
|
+
tenant: str | None = None
|
|
273
|
+
hooks: bool = True
|
|
274
|
+
memory: bool = True
|
|
275
|
+
totp: Callable[[], str] | None = field(default=None, repr=False)
|
|
276
|
+
env: Mapping[str, str] | None = field(default=None, repr=False)
|
|
277
|
+
ancestor: Callable[[], str | None] = field(default=_ancestor_runtime, repr=False)
|
|
278
|
+
transport: httpx.BaseTransport | None = field(default=None, repr=False)
|
|
279
|
+
setup_transport: httpx.AsyncBaseTransport | None = field(default=None, repr=False)
|
|
280
|
+
codex_config: Path | None = None
|
|
281
|
+
|
|
282
|
+
|
|
283
|
+
def _active(row: dict) -> bool:
|
|
284
|
+
return not row.get("revoked_at")
|
|
285
|
+
|
|
286
|
+
|
|
287
|
+
def choose_name(
|
|
288
|
+
options: JoinOptions, runtime: str, directory: Path, rows: list[dict], user_id: str, tag: str
|
|
289
|
+
):
|
|
290
|
+
"""Return (agent name, how it was chosen). Refuse a name another person's keys own."""
|
|
291
|
+
mine_here = [
|
|
292
|
+
row
|
|
293
|
+
for row in rows
|
|
294
|
+
if _active(row)
|
|
295
|
+
and str(row.get("created_by")) == user_id
|
|
296
|
+
and str(row.get("label", "")).startswith(f"{LABEL_PREFIX}: {runtime} ")
|
|
297
|
+
and str(row.get("label", "")).endswith(f"[dir {tag}]")
|
|
298
|
+
]
|
|
299
|
+
if options.name:
|
|
300
|
+
name = sanitize_name(options.name)
|
|
301
|
+
if not name:
|
|
302
|
+
raise JoinError(f"--name {options.name!r} has no usable characters (use a-z, 0-9 and -)")
|
|
303
|
+
owners = {str(row.get("created_by")) for row in rows if row.get("home_agent_id") == name}
|
|
304
|
+
if owners and user_id not in owners:
|
|
305
|
+
raise JoinError(
|
|
306
|
+
f"agent {name!r} already exists in this workspace and was created by someone else; "
|
|
307
|
+
"pick another --name"
|
|
308
|
+
)
|
|
309
|
+
return name, "rejoin" if owners else "new"
|
|
310
|
+
if mine_here:
|
|
311
|
+
latest = max(mine_here, key=lambda row: str(row.get("created_at", "")))
|
|
312
|
+
return str(latest["home_agent_id"]), "rejoin"
|
|
313
|
+
taken = {row.get("home_agent_id") for row in rows}
|
|
314
|
+
for _ in range(8):
|
|
315
|
+
name = default_name(runtime, directory)
|
|
316
|
+
if name not in taken:
|
|
317
|
+
return name, "new"
|
|
318
|
+
raise JoinError("could not pick a free agent name; pass --name")
|
|
319
|
+
|
|
320
|
+
|
|
321
|
+
def run_join(options: JoinOptions, echo: Callable[[str], None], run_async) -> int:
|
|
322
|
+
url, _ = check_url(options.url)
|
|
323
|
+
runtime = detect_runtime(options.runtime, options.env, options.ancestor)
|
|
324
|
+
directory = options.directory.expanduser().resolve()
|
|
325
|
+
if not directory.is_dir():
|
|
326
|
+
raise JoinError(f"--dir {directory} is not a directory")
|
|
327
|
+
if not options.email.strip():
|
|
328
|
+
raise JoinError("an email is required (--email or CAURA_EMAIL)")
|
|
329
|
+
if not options.password:
|
|
330
|
+
raise JoinError("a password is required (CAURA_PASSWORD or the prompt)")
|
|
331
|
+
|
|
332
|
+
echo(f"Joining {RUNTIME_NAMES[runtime]} in {show(directory)} to Caura at {url}")
|
|
333
|
+
session = Session(url, options.transport)
|
|
334
|
+
try:
|
|
335
|
+
session.login(options.email.strip(), options.password, options.totp)
|
|
336
|
+
echo(f" [ok] signed in as {options.email.strip()}")
|
|
337
|
+
tenant = (options.tenant or session.tenant_id).strip()
|
|
338
|
+
if not tenant:
|
|
339
|
+
raise JoinError("the sign-in did not name a workspace; pass --tenant")
|
|
340
|
+
if options.tenant:
|
|
341
|
+
choices = session.tenant_options()
|
|
342
|
+
if choices is not None and tenant not in choices:
|
|
343
|
+
raise JoinError(
|
|
344
|
+
f"workspace {tenant!r} is not in your organization; choices: {', '.join(choices)}"
|
|
345
|
+
)
|
|
346
|
+
echo(f" [ok] workspace {tenant}" + ("" if options.tenant else " (your landing workspace)"))
|
|
347
|
+
|
|
348
|
+
tag = dir_tag(directory)
|
|
349
|
+
rows = session.agent_keys(tenant)
|
|
350
|
+
name, how = choose_name(options, runtime, directory, rows, session.user_id, tag)
|
|
351
|
+
if options.name and name != options.name:
|
|
352
|
+
echo(f" [..] agent name {options.name!r} sanitized to {name!r}")
|
|
353
|
+
replace = [
|
|
354
|
+
row
|
|
355
|
+
for row in rows
|
|
356
|
+
if _active(row)
|
|
357
|
+
and row.get("home_agent_id") == name
|
|
358
|
+
and str(row.get("created_by")) == session.user_id
|
|
359
|
+
and str(row.get("label", "")).startswith(LABEL_PREFIX)
|
|
360
|
+
and str(row.get("label", "")).endswith(f"[dir {tag}]")
|
|
361
|
+
]
|
|
362
|
+
others = [
|
|
363
|
+
row for row in rows if _active(row) and row.get("home_agent_id") == name and row not in replace
|
|
364
|
+
]
|
|
365
|
+
|
|
366
|
+
credential = session.mint(tenant, name, make_label(runtime, directory, tag))
|
|
367
|
+
key = credential["raw_key"]
|
|
368
|
+
verb = "re-joined (new key)" if how == "rejoin" else "created"
|
|
369
|
+
echo(f" [ok] agent {name} {verb}: key {credential.get('key_prefix', 'mc_')}… minted")
|
|
370
|
+
|
|
371
|
+
setup_options = Options(
|
|
372
|
+
runtime=runtime,
|
|
373
|
+
url=url,
|
|
374
|
+
key=key,
|
|
375
|
+
directory=directory,
|
|
376
|
+
agent_id=name,
|
|
377
|
+
description=options.description,
|
|
378
|
+
hooks=options.hooks,
|
|
379
|
+
memory=options.memory,
|
|
380
|
+
codex_config=options.codex_config,
|
|
381
|
+
transport=options.setup_transport,
|
|
382
|
+
next_steps=False,
|
|
383
|
+
)
|
|
384
|
+
try:
|
|
385
|
+
code = run_setup(setup_options, echo, run_async)
|
|
386
|
+
except (SetupError, ValueError, OSError) as exc:
|
|
387
|
+
code, failure = 1, str(exc).replace(key, "****")
|
|
388
|
+
else:
|
|
389
|
+
failure = "" if code == 0 else "setup did not finish"
|
|
390
|
+
if code != 0:
|
|
391
|
+
revoked = session.revoke(str(credential["id"]))
|
|
392
|
+
raise JoinError(
|
|
393
|
+
f"{failure}; the new key was {'revoked' if revoked else 'NOT revoked (revoke it in API Credentials)'}"
|
|
394
|
+
". Fix the problem above and run join again."
|
|
395
|
+
)
|
|
396
|
+
|
|
397
|
+
for row in replace:
|
|
398
|
+
done = session.revoke(str(row["id"]))
|
|
399
|
+
echo(
|
|
400
|
+
f" [{'ok' if done else '!!'}] previous join key {row.get('key_prefix', '')}… for this directory "
|
|
401
|
+
+ ("revoked" if done else "could NOT be revoked; revoke it in API Credentials")
|
|
402
|
+
)
|
|
403
|
+
if others:
|
|
404
|
+
echo(
|
|
405
|
+
f" [..] left {len(others)} other active key(s) for {name} untouched "
|
|
406
|
+
"(not minted by join for this directory)"
|
|
407
|
+
)
|
|
408
|
+
finally:
|
|
409
|
+
session.close()
|
|
410
|
+
|
|
411
|
+
echo("")
|
|
412
|
+
echo(f"Agent {name} is registered in workspace {tenant}.")
|
|
413
|
+
echo("Next steps:")
|
|
414
|
+
if runtime == "claude":
|
|
415
|
+
loaded = "caura-bus and the caura memory server" if options.memory else "caura-bus"
|
|
416
|
+
echo(f" 1. Restart this Claude Code session (or run /mcp) to load {loaded}.")
|
|
417
|
+
echo(" An MCP server added while a session is running is not loaded until the session restarts.")
|
|
418
|
+
if options.hooks:
|
|
419
|
+
echo(" Wake-up hooks are read at startup too, so a restart is best.")
|
|
420
|
+
else:
|
|
421
|
+
echo(" 1. Restart Codex in this directory: MCP servers load only when Codex starts.")
|
|
422
|
+
echo(f" cd {shell_path(directory)} && codex")
|
|
423
|
+
if setup_options.waker:
|
|
424
|
+
echo(" Send it one message, then keep this running in another terminal for wake-ups:")
|
|
425
|
+
echo(f" {setup_options.waker}")
|
|
426
|
+
echo(' 2. Try: "Use the caura-bus peer tool: discover available peers and tell me who they are."')
|
|
427
|
+
if options.memory:
|
|
428
|
+
listing = "claude mcp list" if runtime == "claude" else "codex mcp list"
|
|
429
|
+
echo(f" `{listing}` should show this server's memory MCP at {url}/mcp/.")
|
|
430
|
+
if setup_options.config_path:
|
|
431
|
+
config_path = setup_options.config_path
|
|
432
|
+
key_hint = (
|
|
433
|
+
f"--key-file {shell_path(config_path.with_suffix('.key'))}"
|
|
434
|
+
if options.hooks
|
|
435
|
+
else "(export CAURA_API_KEY first)"
|
|
436
|
+
)
|
|
437
|
+
echo(f" 3. Check the connection: caura-bus doctor --config {shell_path(config_path)} {key_hint}")
|
|
438
|
+
return 0
|