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/main.py
ADDED
|
@@ -0,0 +1,530 @@
|
|
|
1
|
+
"""Operator CLI using the same Caura credential boundary as every other client."""
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
import getpass
|
|
5
|
+
import json
|
|
6
|
+
import os
|
|
7
|
+
import shlex
|
|
8
|
+
import subprocess
|
|
9
|
+
from importlib.metadata import version as package_version
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
from typing import Literal
|
|
12
|
+
|
|
13
|
+
import httpx
|
|
14
|
+
import typer
|
|
15
|
+
from caura_bus_core import RESYNC_EVENT, Bus, PlatformError, ResponseCollector, SendMessage, load_config
|
|
16
|
+
from caura_bus_core.config import CONFIG_ENV_VAR, DEFAULT_CONFIG_PATH
|
|
17
|
+
|
|
18
|
+
from . import broker as broker_mod
|
|
19
|
+
from .hooks import DEFAULT_LISTEN_SECONDS, MAX_LISTEN_SECONDS, install_hooks
|
|
20
|
+
from .join import JoinError, JoinOptions, run_join
|
|
21
|
+
from .runtime import (
|
|
22
|
+
WAKE_RECHECK_SECONDS,
|
|
23
|
+
CodexQueue,
|
|
24
|
+
WakeState,
|
|
25
|
+
listen,
|
|
26
|
+
load_key,
|
|
27
|
+
receive,
|
|
28
|
+
state_path,
|
|
29
|
+
supervise,
|
|
30
|
+
)
|
|
31
|
+
from .setup import Options, SetupError, run_setup
|
|
32
|
+
|
|
33
|
+
app = typer.Typer(add_completion=False, no_args_is_help=True, help="Caura agent messaging.")
|
|
34
|
+
agents_app = typer.Typer(no_args_is_help=True)
|
|
35
|
+
threads_app = typer.Typer(no_args_is_help=True)
|
|
36
|
+
app.add_typer(agents_app, name="agents")
|
|
37
|
+
app.add_typer(threads_app, name="threads")
|
|
38
|
+
hooks_app = typer.Typer(no_args_is_help=True)
|
|
39
|
+
app.add_typer(hooks_app, name="hooks")
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def show_version(value: bool):
|
|
43
|
+
if value:
|
|
44
|
+
typer.echo("caura-bus " + package_version("caura-bus-cli"))
|
|
45
|
+
raise typer.Exit()
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
@app.callback()
|
|
49
|
+
def options(version: bool = typer.Option(False, "--version", callback=show_version, is_eager=True)):
|
|
50
|
+
"""Caura agent messaging."""
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
@app.command()
|
|
54
|
+
def wake(
|
|
55
|
+
runtime: str = typer.Option(...),
|
|
56
|
+
thread: str | None = typer.Option(
|
|
57
|
+
None, help="Codex session id or exact name, or 'latest' for the newest session in --dir"
|
|
58
|
+
),
|
|
59
|
+
directory: Path = typer.Option(Path("."), "--dir", help="Project directory for --thread latest"),
|
|
60
|
+
config: Path | None = typer.Option(None),
|
|
61
|
+
state: Path | None = typer.Option(None),
|
|
62
|
+
key_file: Path | None = typer.Option(None, help="0600 file holding the agent key"),
|
|
63
|
+
):
|
|
64
|
+
"""Wake a Codex session through its native queue. Claude Code uses native hooks."""
|
|
65
|
+
try:
|
|
66
|
+
if runtime not in {"codex", "claude-code"}:
|
|
67
|
+
raise ValueError("Cursor automatic wake is unsupported in this release")
|
|
68
|
+
if runtime == "codex" and not thread:
|
|
69
|
+
raise ValueError("--thread is required for Codex native queue delivery")
|
|
70
|
+
if runtime == "claude-code":
|
|
71
|
+
raise ValueError(
|
|
72
|
+
"Claude Code has no session queue API; use hooks install "
|
|
73
|
+
"--runtime claude-code --dir PROJECT for its background wake listener"
|
|
74
|
+
)
|
|
75
|
+
if not load_key(key_file):
|
|
76
|
+
raise RuntimeError("no agent key: export CAURA_API_KEY or pass --key-file")
|
|
77
|
+
cfg = load_config(config)
|
|
78
|
+
asyncio.run(
|
|
79
|
+
supervise(
|
|
80
|
+
cfg,
|
|
81
|
+
runtime,
|
|
82
|
+
WakeState(state or state_path(cfg), recheck_seconds=WAKE_RECHECK_SECONDS),
|
|
83
|
+
CodexQueue(thread, directory=directory.expanduser().resolve())
|
|
84
|
+
if runtime == "codex"
|
|
85
|
+
else None,
|
|
86
|
+
)
|
|
87
|
+
)
|
|
88
|
+
except (KeyboardInterrupt, asyncio.CancelledError):
|
|
89
|
+
typer.echo("Caura waker stopped.", err=True)
|
|
90
|
+
except PlatformError as exc:
|
|
91
|
+
if exc.status in {401, 403}:
|
|
92
|
+
typer.echo("Caura credential revoked or unauthorized; waker stopped.", err=True)
|
|
93
|
+
return
|
|
94
|
+
typer.echo(str(exc), err=True)
|
|
95
|
+
raise typer.Exit(1) from exc
|
|
96
|
+
except (ValueError, RuntimeError, OSError, TimeoutError) as exc:
|
|
97
|
+
typer.echo(str(exc), err=True)
|
|
98
|
+
raise typer.Exit(1) from exc
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
@app.command()
|
|
102
|
+
def recv(
|
|
103
|
+
brief: bool = typer.Option(False),
|
|
104
|
+
hook: str | None = None,
|
|
105
|
+
config: Path | None = typer.Option(None),
|
|
106
|
+
state: Path | None = typer.Option(None),
|
|
107
|
+
wait: float = typer.Option(0, min=0, max=MAX_LISTEN_SECONDS),
|
|
108
|
+
idle_listen_seconds: float = typer.Option(5, min=0, max=3600),
|
|
109
|
+
key_file: Path | None = typer.Option(None, help="0600 file holding the agent key"),
|
|
110
|
+
):
|
|
111
|
+
"""Emit a fixed wake hint without claiming, acknowledging, or reading message bodies.
|
|
112
|
+
|
|
113
|
+
``--hook Rewake`` is the background Claude Code listener: it waits up to
|
|
114
|
+
``--wait`` seconds and exits 2 with the wake text on stderr when new work
|
|
115
|
+
arrives, which an ``asyncRewake`` hook turns into a new turn.
|
|
116
|
+
"""
|
|
117
|
+
config = config or Path(os.environ.get(CONFIG_ENV_VAR, str(DEFAULT_CONFIG_PATH)))
|
|
118
|
+
try:
|
|
119
|
+
has_key = load_key(key_file)
|
|
120
|
+
except (ValueError, OSError) as exc:
|
|
121
|
+
typer.echo(str(exc), err=True)
|
|
122
|
+
raise typer.Exit(1) from exc
|
|
123
|
+
if not has_key or not config.is_file():
|
|
124
|
+
# Project hooks also run in ordinary contributors' sessions. Missing
|
|
125
|
+
# identity is opt-out, and must not create a hook error or touch state.
|
|
126
|
+
return
|
|
127
|
+
try:
|
|
128
|
+
if not brief or hook not in {None, "Stop", "UserPromptSubmit", "Rewake"}:
|
|
129
|
+
raise ValueError("use --brief, optionally with --hook Rewake, Stop or UserPromptSubmit")
|
|
130
|
+
cfg = load_config(config)
|
|
131
|
+
if hook == "Rewake":
|
|
132
|
+
pid = os.environ.get("CLAUDE_PID", "")
|
|
133
|
+
message = asyncio.run(
|
|
134
|
+
listen(
|
|
135
|
+
cfg,
|
|
136
|
+
WakeState(state or state_path(cfg)),
|
|
137
|
+
wait or DEFAULT_LISTEN_SECONDS,
|
|
138
|
+
int(pid) if pid.isdigit() else None,
|
|
139
|
+
)
|
|
140
|
+
)
|
|
141
|
+
output = ""
|
|
142
|
+
else:
|
|
143
|
+
message = None
|
|
144
|
+
output = asyncio.run(
|
|
145
|
+
receive(cfg, WakeState(state or state_path(cfg)), hook, wait, idle_listen_seconds)
|
|
146
|
+
)
|
|
147
|
+
if output:
|
|
148
|
+
typer.echo(output)
|
|
149
|
+
except (PlatformError, httpx.TransportError, ValueError, RuntimeError, OSError) as exc:
|
|
150
|
+
typer.echo(str(exc), err=True)
|
|
151
|
+
raise typer.Exit(1) from exc
|
|
152
|
+
if message:
|
|
153
|
+
# asyncRewake: exit code 2 starts a Claude Code turn with this text.
|
|
154
|
+
typer.echo(message, err=True)
|
|
155
|
+
raise typer.Exit(2)
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
@hooks_app.command("install")
|
|
159
|
+
def hooks_install(
|
|
160
|
+
runtime: str = typer.Option(...),
|
|
161
|
+
scope: Literal["project", "user"] = "project",
|
|
162
|
+
directory: Path | None = typer.Option(None, "--dir", help="Project directory (default: current)"),
|
|
163
|
+
config: Path | None = typer.Option(None),
|
|
164
|
+
state: Path | None = typer.Option(None),
|
|
165
|
+
key_file: Path | None = typer.Option(None, help="0600 file holding the agent key"),
|
|
166
|
+
listen_seconds: int = typer.Option(DEFAULT_LISTEN_SECONDS, min=60, max=MAX_LISTEN_SECONDS),
|
|
167
|
+
idle_listen_seconds: int = typer.Option(5, min=0, max=3600, hidden=True),
|
|
168
|
+
shared: bool = typer.Option(False),
|
|
169
|
+
):
|
|
170
|
+
"""Install Claude Code wake hooks (background listener) into project local settings."""
|
|
171
|
+
try:
|
|
172
|
+
if runtime != "claude-code":
|
|
173
|
+
raise ValueError("hooks support claude-code; use wake for Codex; Cursor unsupported")
|
|
174
|
+
typer.echo(
|
|
175
|
+
json.dumps(
|
|
176
|
+
install_hooks(
|
|
177
|
+
scope,
|
|
178
|
+
project=directory.expanduser().resolve() if directory else None,
|
|
179
|
+
config=config,
|
|
180
|
+
key_file=key_file,
|
|
181
|
+
state=state,
|
|
182
|
+
listen_seconds=listen_seconds,
|
|
183
|
+
idle_listen_seconds=idle_listen_seconds,
|
|
184
|
+
shared=shared,
|
|
185
|
+
),
|
|
186
|
+
indent=2,
|
|
187
|
+
)
|
|
188
|
+
)
|
|
189
|
+
except (ValueError, RuntimeError, OSError) as exc:
|
|
190
|
+
typer.echo(str(exc), err=True)
|
|
191
|
+
raise typer.Exit(1) from exc
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
@app.command()
|
|
195
|
+
def setup(
|
|
196
|
+
runtime: str = typer.Option(..., help="claude or codex"),
|
|
197
|
+
url: str = typer.Option(..., help="Caura gateway origin, e.g. https://caura.example"),
|
|
198
|
+
key: str | None = typer.Option(
|
|
199
|
+
None, help="Agent key (or set CAURA_API_KEY; prompted when absent)", envvar="CAURA_API_KEY"
|
|
200
|
+
),
|
|
201
|
+
directory: Path = typer.Option(Path("."), "--dir", help="Project directory the runtime starts in"),
|
|
202
|
+
agent_id: str | None = typer.Option(None, help="Expected agent id; setup fails if the key differs"),
|
|
203
|
+
description: str | None = typer.Option(None, help="Register this agent's expertise for discovery"),
|
|
204
|
+
config: Path | None = typer.Option(None, help="Agent config path (default: per-user config dir)"),
|
|
205
|
+
dry_run: bool = typer.Option(False, "--dry-run", help="Verify the key and print the plan only"),
|
|
206
|
+
hooks: bool = typer.Option(
|
|
207
|
+
False,
|
|
208
|
+
"--hooks",
|
|
209
|
+
help="Also enable wake-ups: Claude Code hooks, or the Codex waker command (stores the key 0600)",
|
|
210
|
+
),
|
|
211
|
+
no_memory: bool = typer.Option(
|
|
212
|
+
False, "--no-memory", help="Do not register this server's Caura memory MCP (`caura`)"
|
|
213
|
+
),
|
|
214
|
+
):
|
|
215
|
+
"""Connect Claude Code or Codex to Caura: verify the key, write config, wire MCP and instructions."""
|
|
216
|
+
if not key:
|
|
217
|
+
key = typer.prompt("Caura agent key", hide_input=True)
|
|
218
|
+
options = Options(
|
|
219
|
+
runtime=runtime,
|
|
220
|
+
url=url,
|
|
221
|
+
key=key or "",
|
|
222
|
+
directory=directory,
|
|
223
|
+
agent_id=agent_id,
|
|
224
|
+
description=description,
|
|
225
|
+
config=config,
|
|
226
|
+
dry_run=dry_run,
|
|
227
|
+
hooks=hooks,
|
|
228
|
+
memory=not no_memory,
|
|
229
|
+
)
|
|
230
|
+
try:
|
|
231
|
+
code = run_setup(options, typer.echo, asyncio.run)
|
|
232
|
+
except (SetupError, ValueError, OSError) as exc:
|
|
233
|
+
typer.echo(f"caura-bus setup: {exc}", err=True)
|
|
234
|
+
raise typer.Exit(1) from None
|
|
235
|
+
raise typer.Exit(code)
|
|
236
|
+
|
|
237
|
+
|
|
238
|
+
def tty_prompt(label: str, secret: bool = False) -> str:
|
|
239
|
+
"""Prompt on the controlling terminal, also when stdin is a pipe (``curl ... | sh``)."""
|
|
240
|
+
try:
|
|
241
|
+
tty = open("/dev/tty", "r+")
|
|
242
|
+
except OSError:
|
|
243
|
+
raise JoinError(f"no terminal to ask for the {label.lower()}") from None
|
|
244
|
+
with tty:
|
|
245
|
+
if secret:
|
|
246
|
+
return getpass.getpass(f"{label}: ", stream=tty)
|
|
247
|
+
tty.write(f"{label}: ")
|
|
248
|
+
tty.flush()
|
|
249
|
+
return tty.readline().strip()
|
|
250
|
+
|
|
251
|
+
|
|
252
|
+
@app.command()
|
|
253
|
+
def join(
|
|
254
|
+
url: str = typer.Option(..., help="Caura gateway origin, e.g. https://caura.example"),
|
|
255
|
+
email: str | None = typer.Option(None, envvar="CAURA_EMAIL", help="Your dashboard sign-in email"),
|
|
256
|
+
name: str | None = typer.Option(None, help="Agent name (default: <runtime>-<dir>-<4 hex>)"),
|
|
257
|
+
runtime: str = typer.Option("auto", help="auto, claude or codex"),
|
|
258
|
+
directory: Path = typer.Option(Path("."), "--dir", help="Project directory the runtime starts in"),
|
|
259
|
+
description: str | None = typer.Option(None, help="Register this agent's expertise for discovery"),
|
|
260
|
+
tenant: str | None = typer.Option(None, help="Workspace (default: your landing workspace)"),
|
|
261
|
+
no_hooks: bool = typer.Option(False, "--no-hooks", help="Skip wake-up hooks / the Codex waker"),
|
|
262
|
+
no_memory: bool = typer.Option(
|
|
263
|
+
False, "--no-memory", help="Do not register this server's Caura memory MCP (`caura`)"
|
|
264
|
+
),
|
|
265
|
+
broker: bool | None = typer.Option(
|
|
266
|
+
None,
|
|
267
|
+
"--broker/--no-broker",
|
|
268
|
+
help="Use the installed Caura broker (`caura agent connect`) without asking, or never",
|
|
269
|
+
),
|
|
270
|
+
):
|
|
271
|
+
"""Sign in as yourself, mint an agent key and connect this runtime: no dashboard step needed.
|
|
272
|
+
|
|
273
|
+
The password is read from CAURA_PASSWORD or prompted on the terminal. It is
|
|
274
|
+
used for this one sign-in only and never stored. When the Caura broker
|
|
275
|
+
(`caura`) is installed, join prints the equivalent `caura agent connect`
|
|
276
|
+
command and offers to run it instead.
|
|
277
|
+
"""
|
|
278
|
+
if broker is not False:
|
|
279
|
+
prefer_broker(url, name, runtime, directory, description, tenant, not no_hooks, broker, not no_memory)
|
|
280
|
+
try:
|
|
281
|
+
# Take the secrets out of the environment so no child process (claude mcp add) inherits them.
|
|
282
|
+
password = os.environ.pop("CAURA_PASSWORD", "")
|
|
283
|
+
totp_env = os.environ.pop("CAURA_TOTP_CODE", "").strip()
|
|
284
|
+
if not email:
|
|
285
|
+
email = tty_prompt("Caura email")
|
|
286
|
+
password = password or tty_prompt("Caura password", secret=True)
|
|
287
|
+
code = run_join(
|
|
288
|
+
JoinOptions(
|
|
289
|
+
url=url,
|
|
290
|
+
email=email,
|
|
291
|
+
password=password,
|
|
292
|
+
directory=directory,
|
|
293
|
+
runtime=runtime,
|
|
294
|
+
name=name,
|
|
295
|
+
description=description,
|
|
296
|
+
tenant=tenant,
|
|
297
|
+
hooks=not no_hooks,
|
|
298
|
+
memory=not no_memory,
|
|
299
|
+
totp=(lambda: totp_env) if totp_env else (lambda: tty_prompt("Two-factor code", secret=True)),
|
|
300
|
+
),
|
|
301
|
+
typer.echo,
|
|
302
|
+
asyncio.run,
|
|
303
|
+
)
|
|
304
|
+
except JoinError as exc:
|
|
305
|
+
hint = ""
|
|
306
|
+
if "no terminal" in str(exc):
|
|
307
|
+
hint = " (set CAURA_EMAIL / CAURA_PASSWORD, or run the command in a terminal)"
|
|
308
|
+
typer.echo(f"caura-bus join: {exc}{hint}", err=True)
|
|
309
|
+
raise typer.Exit(1) from None
|
|
310
|
+
except (SetupError, ValueError, OSError) as exc:
|
|
311
|
+
typer.echo(f"caura-bus join: {exc}", err=True)
|
|
312
|
+
raise typer.Exit(1) from None
|
|
313
|
+
raise typer.Exit(code)
|
|
314
|
+
|
|
315
|
+
|
|
316
|
+
def prefer_broker(url, name, runtime, directory, description, tenant, hooks, required, memory=True):
|
|
317
|
+
"""Offer `caura agent connect` when the broker is installed; return to continue with join."""
|
|
318
|
+
found = broker_mod.find_broker()
|
|
319
|
+
if found is None:
|
|
320
|
+
if required:
|
|
321
|
+
typer.echo("caura-bus join: --broker needs the Caura broker (`caura`) on PATH", err=True)
|
|
322
|
+
raise typer.Exit(1)
|
|
323
|
+
return
|
|
324
|
+
try:
|
|
325
|
+
plan = broker_mod.connect_plan(
|
|
326
|
+
found,
|
|
327
|
+
url=url,
|
|
328
|
+
runtime=runtime,
|
|
329
|
+
directory=directory,
|
|
330
|
+
name=name,
|
|
331
|
+
description=description,
|
|
332
|
+
tenant=tenant,
|
|
333
|
+
hooks=hooks,
|
|
334
|
+
memory=memory,
|
|
335
|
+
)
|
|
336
|
+
except JoinError as exc:
|
|
337
|
+
if required:
|
|
338
|
+
typer.echo(f"caura-bus join: {exc}", err=True)
|
|
339
|
+
raise typer.Exit(1) from None
|
|
340
|
+
return # join reports the same problem with its own wording
|
|
341
|
+
typer.echo("The Caura broker is installed. It connects this runtime with a browser sign-in and keeps")
|
|
342
|
+
typer.echo("the agent key in the OS keychain. The equivalent command is:")
|
|
343
|
+
typer.echo(f" {shlex.join(plan.argv)}")
|
|
344
|
+
for note in plan.notes:
|
|
345
|
+
typer.echo(f" note: {note}")
|
|
346
|
+
if required and not plan.runnable:
|
|
347
|
+
typer.echo("caura-bus join: --broker cannot run the command as shown", err=True)
|
|
348
|
+
raise typer.Exit(1)
|
|
349
|
+
run_it = bool(required)
|
|
350
|
+
if not run_it and plan.runnable and broker_mod.interactive():
|
|
351
|
+
run_it = typer.confirm("Run it now instead of signing in here?", default=True)
|
|
352
|
+
if not run_it:
|
|
353
|
+
typer.echo(
|
|
354
|
+
"Continuing with caura-bus join (pass --broker to use the broker, --no-broker to skip this)."
|
|
355
|
+
)
|
|
356
|
+
typer.echo("")
|
|
357
|
+
return
|
|
358
|
+
try:
|
|
359
|
+
code = subprocess.run(plan.argv, check=False).returncode
|
|
360
|
+
except OSError as exc:
|
|
361
|
+
typer.echo(f"caura-bus join: could not start the broker: {exc}", err=True)
|
|
362
|
+
raise typer.Exit(1) from None
|
|
363
|
+
raise typer.Exit(code)
|
|
364
|
+
|
|
365
|
+
|
|
366
|
+
def execute(config, operation, key_file=None):
|
|
367
|
+
try:
|
|
368
|
+
load_key(key_file)
|
|
369
|
+
except (ValueError, OSError) as exc:
|
|
370
|
+
typer.echo(str(exc), err=True)
|
|
371
|
+
raise typer.Exit(1) from exc
|
|
372
|
+
|
|
373
|
+
async def run():
|
|
374
|
+
async with Bus(load_config(config)) as bus:
|
|
375
|
+
result = await operation(bus)
|
|
376
|
+
typer.echo(json.dumps(result, ensure_ascii=False, indent=2))
|
|
377
|
+
|
|
378
|
+
try:
|
|
379
|
+
asyncio.run(run())
|
|
380
|
+
except (PlatformError, httpx.TransportError, ValueError, RuntimeError, OSError) as exc:
|
|
381
|
+
typer.echo(str(exc), err=True)
|
|
382
|
+
raise typer.Exit(1) from exc
|
|
383
|
+
|
|
384
|
+
|
|
385
|
+
@app.command()
|
|
386
|
+
def doctor(
|
|
387
|
+
config: Path | None = typer.Option(None),
|
|
388
|
+
key_file: Path | None = typer.Option(None, help="0600 file holding the agent key"),
|
|
389
|
+
):
|
|
390
|
+
"""Verify gateway access and the credential's effective identity."""
|
|
391
|
+
execute(config, lambda bus: bus.connect(), key_file)
|
|
392
|
+
|
|
393
|
+
|
|
394
|
+
@app.command()
|
|
395
|
+
def send(
|
|
396
|
+
body: str,
|
|
397
|
+
to: list[str] = typer.Option(..., "--to"),
|
|
398
|
+
idempotency_key: str = typer.Option(..., "--idempotency-key"),
|
|
399
|
+
kind: str = "info",
|
|
400
|
+
thread: str | None = None,
|
|
401
|
+
reply_to: str | None = None,
|
|
402
|
+
expect_reply_within_seconds: int | None = typer.Option(None, min=60, max=604800),
|
|
403
|
+
capability: str | None = None,
|
|
404
|
+
config: Path | None = typer.Option(None),
|
|
405
|
+
key_file: Path | None = typer.Option(None, help="0600 file holding the agent key"),
|
|
406
|
+
):
|
|
407
|
+
"""Send a message; keep the idempotency key to safely retry an uncertain send."""
|
|
408
|
+
|
|
409
|
+
async def run(bus):
|
|
410
|
+
receipt = await bus.send(
|
|
411
|
+
SendMessage(
|
|
412
|
+
to=to,
|
|
413
|
+
body=body,
|
|
414
|
+
kind=kind,
|
|
415
|
+
thread_id=thread,
|
|
416
|
+
reply_to=reply_to,
|
|
417
|
+
expect_reply_within_seconds=expect_reply_within_seconds,
|
|
418
|
+
capability=capability,
|
|
419
|
+
),
|
|
420
|
+
idempotency_key=idempotency_key,
|
|
421
|
+
)
|
|
422
|
+
return receipt.model_dump()
|
|
423
|
+
|
|
424
|
+
execute(config, run, key_file)
|
|
425
|
+
|
|
426
|
+
|
|
427
|
+
@app.command()
|
|
428
|
+
def replay(
|
|
429
|
+
limit: int = 20,
|
|
430
|
+
thread: str | None = None,
|
|
431
|
+
peer: str | None = None,
|
|
432
|
+
before: str | None = None,
|
|
433
|
+
reply_to: str | None = typer.Option(None, "--reply-to"),
|
|
434
|
+
config: Path | None = typer.Option(None),
|
|
435
|
+
):
|
|
436
|
+
"""Read your sent and received messages without consuming them."""
|
|
437
|
+
execute(
|
|
438
|
+
config,
|
|
439
|
+
lambda bus: bus.recent(
|
|
440
|
+
limit=limit, thread_id=thread, peer_agent_id=peer, before=before, reply_to=reply_to
|
|
441
|
+
),
|
|
442
|
+
)
|
|
443
|
+
|
|
444
|
+
|
|
445
|
+
@app.command()
|
|
446
|
+
def collect(
|
|
447
|
+
message_id: str,
|
|
448
|
+
timeout: float = typer.Option(30, min=0, max=45),
|
|
449
|
+
expected: list[str] | None = typer.Option(None, "--expected"),
|
|
450
|
+
config: Path | None = typer.Option(None),
|
|
451
|
+
):
|
|
452
|
+
"""Wait a bounded time for correlated replies to a sent request; reads only."""
|
|
453
|
+
|
|
454
|
+
async def run(bus):
|
|
455
|
+
result = await ResponseCollector(bus, message_id, expected or None, timeout=timeout).collect()
|
|
456
|
+
return {
|
|
457
|
+
"request_id": result.request_id,
|
|
458
|
+
"outcome": result.outcome,
|
|
459
|
+
"expected": result.expected,
|
|
460
|
+
"answers": [vars(a) for _, a in sorted(result.answers.items())],
|
|
461
|
+
"pending": result.pending,
|
|
462
|
+
"closed": result.closed,
|
|
463
|
+
"summary": result.summary(),
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
execute(config, run)
|
|
467
|
+
|
|
468
|
+
|
|
469
|
+
@app.command()
|
|
470
|
+
def status(message_id: str, config: Path | None = typer.Option(None)):
|
|
471
|
+
"""Inspect a message and its delivery states."""
|
|
472
|
+
execute(config, lambda bus: bus.status(message_id))
|
|
473
|
+
|
|
474
|
+
|
|
475
|
+
@app.command()
|
|
476
|
+
def discover(
|
|
477
|
+
capability: str | None = None,
|
|
478
|
+
include_offline: bool = False,
|
|
479
|
+
config: Path | None = typer.Option(None),
|
|
480
|
+
):
|
|
481
|
+
"""Find connected peers by capability and availability."""
|
|
482
|
+
execute(
|
|
483
|
+
config,
|
|
484
|
+
lambda bus: bus.discover_all(capability=capability, available_only=not include_offline),
|
|
485
|
+
)
|
|
486
|
+
|
|
487
|
+
|
|
488
|
+
@app.command()
|
|
489
|
+
def watch(config: Path | None = typer.Option(None), after: int = 0):
|
|
490
|
+
"""Follow live Caura events; reconnects resume from the durable event cursor."""
|
|
491
|
+
|
|
492
|
+
async def run():
|
|
493
|
+
async with Bus(load_config(config)) as bus:
|
|
494
|
+
async for event in bus.events(after=after):
|
|
495
|
+
if event["event_type"] == RESYNC_EVENT:
|
|
496
|
+
typer.echo(
|
|
497
|
+
f"Caura: events before #{event['seq']} were removed by retention; "
|
|
498
|
+
"current inbox state was reloaded.",
|
|
499
|
+
err=True,
|
|
500
|
+
)
|
|
501
|
+
typer.echo(json.dumps(event, ensure_ascii=False))
|
|
502
|
+
|
|
503
|
+
try:
|
|
504
|
+
asyncio.run(run())
|
|
505
|
+
except KeyboardInterrupt:
|
|
506
|
+
typer.echo("Caura event watch stopped.", err=True)
|
|
507
|
+
|
|
508
|
+
|
|
509
|
+
@agents_app.command("list")
|
|
510
|
+
def agents_list(fleet: str | None = None, config: Path | None = typer.Option(None)):
|
|
511
|
+
execute(config, lambda bus: bus.agents_all(fleet))
|
|
512
|
+
|
|
513
|
+
|
|
514
|
+
@threads_app.command("list")
|
|
515
|
+
def threads_list(config: Path | None = typer.Option(None)):
|
|
516
|
+
execute(config, lambda bus: bus.threads())
|
|
517
|
+
|
|
518
|
+
|
|
519
|
+
@app.command()
|
|
520
|
+
def requests(
|
|
521
|
+
state: str | None = None, limit: int = typer.Option(20, min=1, max=100), config: Path | None = None
|
|
522
|
+
):
|
|
523
|
+
"""List sent requests and retire their active notices."""
|
|
524
|
+
if state not in {None, "awaiting", "overdue", "unanswered"}:
|
|
525
|
+
raise typer.BadParameter("state must be awaiting, overdue or unanswered")
|
|
526
|
+
execute(config, lambda bus: bus.requests(state=state, limit=limit))
|
|
527
|
+
|
|
528
|
+
|
|
529
|
+
if __name__ == "__main__":
|
|
530
|
+
app()
|