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/broker.py
ADDED
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
"""Prefer the Caura broker (`caura agent connect`) when it is installed.
|
|
2
|
+
|
|
3
|
+
The broker connects an agent with a browser sign-in, keeps the agent key in
|
|
4
|
+
the OS keychain and owns the runtime wiring, peer instructions and wake-ups.
|
|
5
|
+
`caura-bus join` stays for hosts without it.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import os
|
|
11
|
+
import shutil
|
|
12
|
+
import subprocess
|
|
13
|
+
import sys
|
|
14
|
+
from collections.abc import Callable, Mapping
|
|
15
|
+
from dataclasses import dataclass, field
|
|
16
|
+
from pathlib import Path
|
|
17
|
+
|
|
18
|
+
from .join import JoinError, default_name, detect_runtime, sanitize_name
|
|
19
|
+
|
|
20
|
+
BROKER = "caura"
|
|
21
|
+
THREAD_PLACEHOLDER = "<CODEX_THREAD_ID>"
|
|
22
|
+
BROKER_RUNTIMES = {"claude": "claude-code", "codex": "codex"}
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
@dataclass
|
|
26
|
+
class Broker:
|
|
27
|
+
path: str
|
|
28
|
+
# Flags `caura agent connect` accepts, e.g. {"agent", "runtime", "description"}.
|
|
29
|
+
flags: set[str] = field(default_factory=set)
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def _connect_flags(usage: str) -> set[str]:
|
|
33
|
+
flags = set()
|
|
34
|
+
for line in usage.splitlines():
|
|
35
|
+
stripped = line.strip()
|
|
36
|
+
if stripped.startswith("-"):
|
|
37
|
+
flags.add(stripped.lstrip("-").split()[0])
|
|
38
|
+
return flags
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def find_broker(
|
|
42
|
+
which: Callable[[str], str | None] = shutil.which,
|
|
43
|
+
run: Callable[..., subprocess.CompletedProcess] = subprocess.run,
|
|
44
|
+
) -> Broker | None:
|
|
45
|
+
"""The `caura` broker on PATH, if it is one that has `agent connect`."""
|
|
46
|
+
path = which(BROKER)
|
|
47
|
+
if not path:
|
|
48
|
+
return None
|
|
49
|
+
try:
|
|
50
|
+
probe = run(
|
|
51
|
+
[path, "agent", "connect", "--help"],
|
|
52
|
+
capture_output=True,
|
|
53
|
+
text=True,
|
|
54
|
+
timeout=5,
|
|
55
|
+
check=False,
|
|
56
|
+
stdin=subprocess.DEVNULL,
|
|
57
|
+
)
|
|
58
|
+
except (OSError, subprocess.SubprocessError):
|
|
59
|
+
return None
|
|
60
|
+
flags = _connect_flags((probe.stdout or "") + (probe.stderr or ""))
|
|
61
|
+
if not {"agent", "runtime", "project", "url"} <= flags:
|
|
62
|
+
return None
|
|
63
|
+
return Broker(path=path, flags=flags)
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
@dataclass
|
|
67
|
+
class BrokerPlan:
|
|
68
|
+
argv: list[str]
|
|
69
|
+
notes: list[str]
|
|
70
|
+
runnable: bool
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def connect_plan(
|
|
74
|
+
broker: Broker,
|
|
75
|
+
*,
|
|
76
|
+
url: str,
|
|
77
|
+
runtime: str,
|
|
78
|
+
directory: Path,
|
|
79
|
+
name: str | None,
|
|
80
|
+
description: str | None,
|
|
81
|
+
tenant: str | None,
|
|
82
|
+
hooks: bool,
|
|
83
|
+
memory: bool = True,
|
|
84
|
+
env: Mapping[str, str] | None = None,
|
|
85
|
+
ancestor: Callable[[], str | None] | None = None,
|
|
86
|
+
) -> BrokerPlan:
|
|
87
|
+
"""The `caura agent connect` command equivalent to these join options."""
|
|
88
|
+
env = os.environ if env is None else env
|
|
89
|
+
resolved = detect_runtime(runtime, env, ancestor) if ancestor else detect_runtime(runtime, env)
|
|
90
|
+
directory = directory.expanduser().resolve()
|
|
91
|
+
agent = sanitize_name(name) if name else ""
|
|
92
|
+
if name and not agent:
|
|
93
|
+
raise JoinError(f"--name {name!r} has no usable characters (use a-z, 0-9 and -)")
|
|
94
|
+
agent = agent or default_name(resolved, directory)
|
|
95
|
+
argv = [
|
|
96
|
+
broker.path,
|
|
97
|
+
"agent",
|
|
98
|
+
"connect",
|
|
99
|
+
"--runtime",
|
|
100
|
+
BROKER_RUNTIMES[resolved],
|
|
101
|
+
"--agent",
|
|
102
|
+
agent,
|
|
103
|
+
"--project",
|
|
104
|
+
str(directory),
|
|
105
|
+
"--url",
|
|
106
|
+
url,
|
|
107
|
+
]
|
|
108
|
+
notes: list[str] = []
|
|
109
|
+
runnable = True
|
|
110
|
+
if resolved == "codex":
|
|
111
|
+
thread = env.get("CODEX_THREAD_ID", "")
|
|
112
|
+
if not thread:
|
|
113
|
+
thread, runnable = THREAD_PLACEHOLDER, False
|
|
114
|
+
notes.append(
|
|
115
|
+
"Codex needs its thread: run this inside the Codex session or replace the placeholder."
|
|
116
|
+
)
|
|
117
|
+
argv += ["--thread", thread]
|
|
118
|
+
if description is not None:
|
|
119
|
+
if "description" in broker.flags:
|
|
120
|
+
argv += ["--description", description]
|
|
121
|
+
else:
|
|
122
|
+
notes.append("This broker cannot register a description; set it later with peer op=describe.")
|
|
123
|
+
if tenant:
|
|
124
|
+
notes.append("--tenant is not passed: the broker uses the workspace you approve in the browser.")
|
|
125
|
+
if not hooks:
|
|
126
|
+
notes.append("--no-hooks is not passed: the broker always installs wake-ups.")
|
|
127
|
+
# A broker that knows --no-memory also registers the server's memory MCP.
|
|
128
|
+
if "no-memory" in broker.flags:
|
|
129
|
+
if not memory:
|
|
130
|
+
argv.append("--no-memory")
|
|
131
|
+
elif memory:
|
|
132
|
+
notes.append(
|
|
133
|
+
"This broker does not register the Caura memory MCP; upgrade it, or use "
|
|
134
|
+
"`caura-bus join --no-broker` to get the memory server wired too."
|
|
135
|
+
)
|
|
136
|
+
return BrokerPlan(argv=argv, notes=notes, runnable=runnable)
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def interactive() -> bool:
|
|
140
|
+
return sys.stdin.isatty() and sys.stdout.isatty()
|
caura_bus_cli/hooks.py
ADDED
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
"""Merge native runtime hooks without replacing unrelated project settings."""
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import shlex
|
|
5
|
+
import shutil
|
|
6
|
+
import sys
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
|
|
9
|
+
# Background listener lifetime per arming. A Stop re-arms it after every turn,
|
|
10
|
+
# but an idle session has no turns: a short window left an open session deaf
|
|
11
|
+
# after one quiet night. The listener already exits with its Claude Code
|
|
12
|
+
# process, so the window only bounds a session left idle for a week.
|
|
13
|
+
DEFAULT_LISTEN_SECONDS = 7 * 24 * 3600
|
|
14
|
+
MAX_LISTEN_SECONDS = DEFAULT_LISTEN_SECONDS
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def install_hooks(
|
|
18
|
+
scope="project",
|
|
19
|
+
project=None,
|
|
20
|
+
config=None,
|
|
21
|
+
state=None,
|
|
22
|
+
listen_seconds=DEFAULT_LISTEN_SECONDS,
|
|
23
|
+
idle_listen_seconds=None,
|
|
24
|
+
shared=False,
|
|
25
|
+
key_file=None,
|
|
26
|
+
):
|
|
27
|
+
"""Install the Claude Code wake hooks.
|
|
28
|
+
|
|
29
|
+
SessionStart and Stop start a background ``asyncRewake`` listener: Claude
|
|
30
|
+
Code does not wait for it, and when it exits 2 Claude starts a new turn
|
|
31
|
+
with the wake text, also when the session is idle. UserPromptSubmit adds a
|
|
32
|
+
quick check to every human turn. ``idle_listen_seconds`` is accepted for
|
|
33
|
+
compatibility with older callers and ignored.
|
|
34
|
+
"""
|
|
35
|
+
del idle_listen_seconds
|
|
36
|
+
root = Path(project or Path.cwd()) if scope == "project" else Path.home()
|
|
37
|
+
if shared and scope != "project":
|
|
38
|
+
raise ValueError("--shared applies only to --scope project")
|
|
39
|
+
filename = "settings.local.json" if scope == "project" and not shared else "settings.json"
|
|
40
|
+
path = root / ".claude" / filename
|
|
41
|
+
settings = json.loads(path.read_text()) if path.exists() else {}
|
|
42
|
+
hooks = settings.setdefault("hooks", {})
|
|
43
|
+
executable = shutil.which("caura-bus") or sibling_executable()
|
|
44
|
+
if not executable:
|
|
45
|
+
raise RuntimeError("caura-bus must be installed on PATH before installing hooks")
|
|
46
|
+
base = [str(Path(executable).absolute()), "recv", "--brief"]
|
|
47
|
+
if config:
|
|
48
|
+
base.extend(["--config", str(Path(config).resolve())])
|
|
49
|
+
if state:
|
|
50
|
+
base.extend(["--state", str(Path(state).resolve())])
|
|
51
|
+
if key_file:
|
|
52
|
+
base.extend(["--key-file", str(Path(key_file).resolve())])
|
|
53
|
+
listener = [*base, "--hook", "Rewake", "--wait", str(listen_seconds)]
|
|
54
|
+
entries_by_event = {
|
|
55
|
+
"SessionStart": {
|
|
56
|
+
"type": "command",
|
|
57
|
+
"command": shlex.join(listener),
|
|
58
|
+
"asyncRewake": True,
|
|
59
|
+
"timeout": listen_seconds + 60,
|
|
60
|
+
},
|
|
61
|
+
"Stop": {
|
|
62
|
+
"type": "command",
|
|
63
|
+
"command": shlex.join(listener),
|
|
64
|
+
"asyncRewake": True,
|
|
65
|
+
"timeout": listen_seconds + 60,
|
|
66
|
+
},
|
|
67
|
+
"UserPromptSubmit": {
|
|
68
|
+
"type": "command",
|
|
69
|
+
"command": shlex.join([*base, "--hook", "UserPromptSubmit"]),
|
|
70
|
+
"timeout": 10,
|
|
71
|
+
},
|
|
72
|
+
}
|
|
73
|
+
written = {}
|
|
74
|
+
# Drop our entries everywhere first, so a reinstall also removes hooks an
|
|
75
|
+
# older version installed under events it no longer uses.
|
|
76
|
+
for event, entries in list(hooks.items()):
|
|
77
|
+
if not isinstance(entries, list):
|
|
78
|
+
continue
|
|
79
|
+
owned = False
|
|
80
|
+
for group in entries:
|
|
81
|
+
kept = [h for h in group.get("hooks", []) if not owned_command(h.get("command", ""))]
|
|
82
|
+
owned = owned or len(kept) != len(group.get("hooks", []))
|
|
83
|
+
group["hooks"] = kept
|
|
84
|
+
if owned:
|
|
85
|
+
entries[:] = [group for group in entries if group.get("hooks")]
|
|
86
|
+
if not entries:
|
|
87
|
+
del hooks[event]
|
|
88
|
+
for event, hook in entries_by_event.items():
|
|
89
|
+
entry = {"hooks": [hook]}
|
|
90
|
+
hooks.setdefault(event, []).append(entry)
|
|
91
|
+
written[event] = entry
|
|
92
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
93
|
+
temporary = path.with_suffix(".json.tmp")
|
|
94
|
+
temporary.write_text(json.dumps(settings, indent=2) + "\n")
|
|
95
|
+
temporary.replace(path)
|
|
96
|
+
return {"path": str(path), "hooks": written}
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def sibling_executable():
|
|
100
|
+
"""caura-bus next to the running interpreter (a venv or pipx install not on PATH)."""
|
|
101
|
+
candidate = Path(sys.executable).parent / "caura-bus"
|
|
102
|
+
return str(candidate) if candidate.is_file() else None
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def owned_command(command):
|
|
106
|
+
try:
|
|
107
|
+
parts = shlex.split(command)
|
|
108
|
+
except ValueError:
|
|
109
|
+
return False
|
|
110
|
+
return (
|
|
111
|
+
len(parts) >= 3
|
|
112
|
+
and Path(parts[0]).name == "caura-bus"
|
|
113
|
+
and parts[1:3] == ["recv", "--brief"]
|
|
114
|
+
and "--hook" in parts
|
|
115
|
+
)
|