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.
@@ -0,0 +1,5 @@
1
+ """caura-bus operator CLI."""
2
+
3
+ from .main import app
4
+
5
+ __all__ = ["app"]
@@ -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
+ )