monkeybot-cli 0.2.1__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.
Files changed (51) hide show
  1. monkeybot_cli/__init__.py +3 -0
  2. monkeybot_cli/chat_renderer.py +87 -0
  3. monkeybot_cli/chat_session.py +911 -0
  4. monkeybot_cli/chat_status_bar.py +205 -0
  5. monkeybot_cli/chat_theme.py +91 -0
  6. monkeybot_cli/chat_tool_display.py +334 -0
  7. monkeybot_cli/chat_tui.py +1491 -0
  8. monkeybot_cli/chat_tui_widgets.py +996 -0
  9. monkeybot_cli/commands/__init__.py +1 -0
  10. monkeybot_cli/commands/chat.py +817 -0
  11. monkeybot_cli/commands/doctor.py +293 -0
  12. monkeybot_cli/commands/loop.py +207 -0
  13. monkeybot_cli/commands/new.py +207 -0
  14. monkeybot_cli/commands/run_cmd.py +41 -0
  15. monkeybot_cli/commands/talk.py +102 -0
  16. monkeybot_cli/commands/validate.py +385 -0
  17. monkeybot_cli/compat.py +7 -0
  18. monkeybot_cli/config_resolve.py +55 -0
  19. monkeybot_cli/exit_commands.py +13 -0
  20. monkeybot_cli/extras_catalog.py +95 -0
  21. monkeybot_cli/gateway_health.py +34 -0
  22. monkeybot_cli/main.py +38 -0
  23. monkeybot_cli/opensandbox_lifecycle.py +314 -0
  24. monkeybot_cli/output.py +110 -0
  25. monkeybot_cli/providers.py +112 -0
  26. monkeybot_cli/realtime/__init__.py +13 -0
  27. monkeybot_cli/realtime/audio_io.py +147 -0
  28. monkeybot_cli/realtime/client.py +17 -0
  29. monkeybot_cli/realtime/gateway_manager.py +142 -0
  30. monkeybot_cli/realtime/push_to_talk.py +128 -0
  31. monkeybot_cli/realtime/session.py +256 -0
  32. monkeybot_cli/realtime/session_controller.py +501 -0
  33. monkeybot_cli/realtime/talk_ui.py +243 -0
  34. monkeybot_cli/realtime/wire_encode.py +39 -0
  35. monkeybot_cli/runtime_python.py +91 -0
  36. monkeybot_cli/scaffold.py +287 -0
  37. monkeybot_cli/scaffold_defaults/AGENT.md +56 -0
  38. monkeybot_cli/scaffold_defaults/__init__.py +1 -0
  39. monkeybot_cli/scaffold_defaults/command_allowlist.yaml +57 -0
  40. monkeybot_cli/scaffold_defaults/env.example +35 -0
  41. monkeybot_cli/scaffold_defaults/mcp.json +49 -0
  42. monkeybot_cli/scaffold_defaults/monkeybot.example.yaml +191 -0
  43. monkeybot_cli/scaffold_defaults/otel-collector.example.yaml +57 -0
  44. monkeybot_cli/scaffold_defaults/permissions.yaml +32 -0
  45. monkeybot_cli/scaffold_defaults/setup-workspace.sh +24 -0
  46. monkeybot_cli/session_controller.py +7 -0
  47. monkeybot_cli/terminal_markdown.py +48 -0
  48. monkeybot_cli-0.2.1.dist-info/METADATA +10 -0
  49. monkeybot_cli-0.2.1.dist-info/RECORD +51 -0
  50. monkeybot_cli-0.2.1.dist-info/WHEEL +4 -0
  51. monkeybot_cli-0.2.1.dist-info/entry_points.txt +2 -0
@@ -0,0 +1,17 @@
1
+ """Deprecated shim — use ``encode_client_frame`` and ``is_exit_command``."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from monkeybot_cli.exit_commands import is_exit_command
6
+ from monkeybot_cli.realtime.wire_encode import encode_client_frame
7
+
8
+
9
+ class RealtimeClientError(RuntimeError):
10
+ """Deprecated alias; prefer ``RuntimeError``."""
11
+
12
+
13
+ __all__ = [
14
+ "RealtimeClientError",
15
+ "encode_client_frame",
16
+ "is_exit_command",
17
+ ]
@@ -0,0 +1,142 @@
1
+ """Helpers for starting the combined (SSE + realtime) gateway from the CLI."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ import logging
7
+ import os
8
+ import urllib.parse
9
+ from pathlib import Path
10
+
11
+ import httpx
12
+
13
+ from monkeybot_cli.runtime_python import (
14
+ COMBINED_GATEWAY_MODULE,
15
+ gateway_argv,
16
+ resolve_runtime_python,
17
+ )
18
+
19
+ logger = logging.getLogger(__name__)
20
+
21
+
22
+ def _find_workspace_dir(start: Path | None = None) -> Path | None:
23
+ """Find the directory containing ``monkeybot_config/monkeybot.yaml``."""
24
+ start = start or Path.cwd()
25
+ for path in [start, *start.parents]:
26
+ if (path / "monkeybot_config" / "monkeybot.yaml").exists():
27
+ return path
28
+ return None
29
+
30
+
31
+ def _load_dotenv(workspace_dir: Path) -> None:
32
+ """Load workspace ``.env`` into the current process environment."""
33
+ env_file = workspace_dir / ".env"
34
+ if not env_file.exists():
35
+ return
36
+ try:
37
+ from dotenv import load_dotenv
38
+
39
+ load_dotenv(env_file, override=False)
40
+ except Exception:
41
+ logger.warning("Failed to load %s", env_file, exc_info=True)
42
+
43
+
44
+ def _http_base_from_ws_url(url: str) -> str:
45
+ parsed = urllib.parse.urlparse(url)
46
+ host = parsed.hostname or "localhost"
47
+ port = parsed.port or (443 if parsed.scheme == "wss" else 80)
48
+ scheme = "https" if parsed.scheme == "wss" else "http"
49
+ return f"{scheme}://{host}:{port}"
50
+
51
+
52
+ async def _is_gateway_healthy(url: str, timeout: float = 2.0) -> bool:
53
+ """Return True if ``GET {http_base}/health`` succeeds."""
54
+ base = _http_base_from_ws_url(url)
55
+ try:
56
+ async with httpx.AsyncClient(timeout=timeout) as client:
57
+ resp = await client.get(f"{base}/health")
58
+ return resp.status_code == 200
59
+ except Exception as exc:
60
+ logger.debug("Gateway health check failed at %s: %s", base, exc)
61
+ return False
62
+
63
+
64
+ async def _wait_for_gateway(url: str, max_wait: float = 30.0) -> bool:
65
+ """Poll health until ready or timeout."""
66
+ deadline = asyncio.get_event_loop().time() + max_wait
67
+ while asyncio.get_event_loop().time() < deadline:
68
+ if await _is_gateway_healthy(url):
69
+ return True
70
+ await asyncio.sleep(0.5)
71
+ return False
72
+
73
+
74
+ def _url_is_local(url: str) -> bool:
75
+ """Return True if the URL points to localhost/127.0.0.1."""
76
+ parsed = urllib.parse.urlparse(url)
77
+ host = (parsed.hostname or "").lower()
78
+ return host in {"localhost", "127.0.0.1", "::1"}
79
+
80
+
81
+ async def start_gateway_if_needed(
82
+ url: str,
83
+ *,
84
+ start: bool = True,
85
+ ) -> asyncio.subprocess.Process | None:
86
+ """Start a local combined gateway if requested and not already healthy."""
87
+ if not start:
88
+ return None
89
+ if not _url_is_local(url):
90
+ return None
91
+ if await _is_gateway_healthy(url):
92
+ logger.info("Gateway already reachable at %s", url)
93
+ return None
94
+
95
+ workspace = _find_workspace_dir()
96
+ if workspace is None:
97
+ raise RuntimeError(
98
+ "Could not find monkeybot_config/monkeybot.yaml to start the gateway. "
99
+ "Run the command from a MonkeyBot workspace or start the gateway manually."
100
+ )
101
+
102
+ _load_dotenv(workspace)
103
+ parsed = urllib.parse.urlparse(url)
104
+ port = parsed.port or (443 if parsed.scheme == "wss" else 80)
105
+
106
+ env = {**os.environ, "PORT": str(port)}
107
+ env.setdefault("LOG_LEVEL", "error")
108
+ runtime = resolve_runtime_python(workspace)
109
+ cmd = gateway_argv(runtime, module=COMBINED_GATEWAY_MODULE)
110
+
111
+ logger.info("Starting combined gateway on port %s from %s", port, workspace)
112
+ proc = await asyncio.create_subprocess_exec(
113
+ *cmd,
114
+ cwd=str(workspace),
115
+ env=env,
116
+ stdout=None,
117
+ stderr=None,
118
+ )
119
+
120
+ if not await _wait_for_gateway(url):
121
+ proc.terminate()
122
+ try:
123
+ await asyncio.wait_for(proc.wait(), timeout=5.0)
124
+ except asyncio.TimeoutError:
125
+ proc.kill()
126
+ await proc.wait()
127
+ raise RuntimeError(f"Gateway failed to become reachable at {url}")
128
+
129
+ logger.info("Gateway ready at %s", url)
130
+ return proc
131
+
132
+
133
+ async def stop_gateway(proc: asyncio.subprocess.Process | None) -> None:
134
+ """Terminate a gateway subprocess started by :func:`start_gateway_if_needed`."""
135
+ if proc is None or proc.returncode is not None:
136
+ return
137
+ proc.terminate()
138
+ try:
139
+ await asyncio.wait_for(proc.wait(), timeout=5.0)
140
+ except asyncio.TimeoutError:
141
+ proc.kill()
142
+ await proc.wait()
@@ -0,0 +1,128 @@
1
+ """Push-to-talk key gate for the realtime CLI.
2
+
3
+ Hold the configured modifier (Command on macOS by default) to unmute the
4
+ microphone. Requires ``pynput`` (installed via ``monkeybot[cli-realtime]``).
5
+ On macOS, grant Accessibility permission to the terminal app if key events
6
+ are not detected.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import logging
12
+ import threading
13
+ from typing import Any
14
+
15
+ logger = logging.getLogger(__name__)
16
+
17
+ try:
18
+ from pynput import keyboard
19
+
20
+ _HAS_PYNPUT = True
21
+ except ImportError:
22
+ keyboard = None
23
+ _HAS_PYNPUT = False
24
+
25
+
26
+ class PushToTalkError(Exception):
27
+ """Push-to-talk setup failed."""
28
+
29
+
30
+ class PushToTalkGate:
31
+ """Tracks whether the push-to-talk key is currently held."""
32
+
33
+ def __init__(self, *, key_name: str = "cmd") -> None:
34
+ if not _HAS_PYNPUT:
35
+ raise PushToTalkError(
36
+ "pynput is required for push-to-talk. Install with: uv sync --extra cli-realtime"
37
+ )
38
+ self._key_name = key_name.lower().strip()
39
+ self._held = False
40
+ self._lock = threading.Lock()
41
+ self._listener: Any | None = None
42
+ self._target_keys = self._resolve_keys(self._key_name)
43
+
44
+ @staticmethod
45
+ def _resolve_keys(key_name: str) -> set[Any]:
46
+ assert keyboard is not None
47
+ aliases: dict[str, set[Any]] = {
48
+ "cmd": {keyboard.Key.cmd, keyboard.Key.cmd_l, keyboard.Key.cmd_r},
49
+ "command": {keyboard.Key.cmd, keyboard.Key.cmd_l, keyboard.Key.cmd_r},
50
+ "meta": {keyboard.Key.cmd, keyboard.Key.cmd_l, keyboard.Key.cmd_r},
51
+ "alt": {keyboard.Key.alt, keyboard.Key.alt_l, keyboard.Key.alt_r},
52
+ "option": {keyboard.Key.alt, keyboard.Key.alt_l, keyboard.Key.alt_r},
53
+ "ctrl": {keyboard.Key.ctrl, keyboard.Key.ctrl_l, keyboard.Key.ctrl_r},
54
+ "control": {keyboard.Key.ctrl, keyboard.Key.ctrl_l, keyboard.Key.ctrl_r},
55
+ "space": {keyboard.Key.space},
56
+ }
57
+ keys = aliases.get(key_name)
58
+ if keys is None:
59
+ raise PushToTalkError(
60
+ f"Unsupported push-to-talk key '{key_name}'. "
61
+ "Use one of: cmd, alt, ctrl, space"
62
+ )
63
+ return keys
64
+
65
+ @property
66
+ def key_label(self) -> str:
67
+ labels = {
68
+ "cmd": "⌘ Command",
69
+ "command": "⌘ Command",
70
+ "meta": "⌘ Command",
71
+ "alt": "⌥ Option",
72
+ "option": "⌥ Option",
73
+ "ctrl": "⌃ Control",
74
+ "control": "⌃ Control",
75
+ "space": "Space",
76
+ }
77
+ return labels.get(self._key_name, self._key_name)
78
+
79
+ def is_held(self) -> bool:
80
+ with self._lock:
81
+ return self._held
82
+
83
+ def _on_press(self, key: Any) -> None:
84
+ if key in self._target_keys:
85
+ with self._lock:
86
+ was_held = self._held
87
+ self._held = True
88
+ if not was_held:
89
+ logger.debug("push-to-talk pressed (%s)", self._key_name)
90
+
91
+ def _on_release(self, key: Any) -> None:
92
+ if key in self._target_keys:
93
+ with self._lock:
94
+ was_held = self._held
95
+ self._held = False
96
+ if was_held:
97
+ logger.debug("push-to-talk released (%s)", self._key_name)
98
+
99
+ def start(self) -> None:
100
+ assert keyboard is not None
101
+ if self._listener is not None:
102
+ return
103
+ self._listener = keyboard.Listener(
104
+ on_press=self._on_press,
105
+ on_release=self._on_release,
106
+ )
107
+ self._listener.daemon = True
108
+ try:
109
+ self._listener.start()
110
+ except Exception as exc:
111
+ self._listener = None
112
+ raise PushToTalkError(
113
+ f"Failed to start push-to-talk listener: {exc}. "
114
+ "On macOS, grant Accessibility permission to your terminal app "
115
+ "(System Settings → Privacy & Security → Accessibility)."
116
+ ) from exc
117
+ logger.info("push-to-talk ready: hold %s to speak", self.key_label)
118
+
119
+ def stop(self) -> None:
120
+ listener = self._listener
121
+ self._listener = None
122
+ if listener is not None:
123
+ try:
124
+ listener.stop()
125
+ except Exception:
126
+ logger.exception("push-to-talk listener stop failed")
127
+ with self._lock:
128
+ self._held = False
@@ -0,0 +1,256 @@
1
+ """Talk session orchestration (audio + text entrypoints)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import logging
6
+ import os
7
+ import sys
8
+ import uuid
9
+ from typing import Annotated
10
+
11
+ import typer
12
+ from monkeybot.core.config.realtime_config import get_realtime_config
13
+ from monkeybot.core.logging_utils import normalize_log_level
14
+
15
+ from monkeybot_cli.realtime.audio_io import AudioIOError, AudioPlayer, AudioRecorder
16
+ from monkeybot_cli.realtime.push_to_talk import PushToTalkError, PushToTalkGate
17
+ from monkeybot_cli.runtime_python import DEFAULT_PORT
18
+
19
+ app = typer.Typer(help="MonkeyBot realtime CLI helpers (prefer the monkeybot-cli package)")
20
+
21
+
22
+ def _setup_logging(*, verbose: bool = False) -> None:
23
+ level = logging.DEBUG if verbose else normalize_log_level(os.getenv("LOG_LEVEL"))
24
+ logging.basicConfig(
25
+ level=level,
26
+ format="%(levelname)s:%(name)s:%(message)s",
27
+ )
28
+
29
+
30
+ def _generate_session_id() -> str:
31
+ return uuid.uuid4().hex
32
+
33
+
34
+ def _parse_sample_rate(fmt: str) -> int:
35
+ parts = fmt.lower().split("_")
36
+ for p in parts:
37
+ if p.endswith("khz"):
38
+ try:
39
+ return int(p.replace("khz", "")) * 1000
40
+ except ValueError:
41
+ pass
42
+ return 24000
43
+
44
+
45
+ def default_gateway_ws_url() -> str:
46
+ """Default WebSocket URL from env or ``runtime.port`` (DEFAULT_PORT)."""
47
+ env = os.getenv("MONKEYBOT_GATEWAY_URL")
48
+ if env:
49
+ return env.rstrip("/")
50
+ return f"ws://127.0.0.1:{DEFAULT_PORT}"
51
+
52
+
53
+ def _setup_audio_devices(
54
+ *,
55
+ text: bool,
56
+ input_format: str,
57
+ chunk_ms: int,
58
+ ptt_key: str,
59
+ ) -> tuple[bool, AudioRecorder | None, AudioPlayer | None, PushToTalkGate | None, int]:
60
+ """Open player/recorder/PTT for talk. Returns (audio_enabled, recorder, player, ptt, exit_code).
61
+
62
+ ``exit_code`` is non-zero when setup failed fatally (caller should return it).
63
+ """
64
+ audio_input_enabled = not text
65
+ recorder: AudioRecorder | None = None
66
+ player: AudioPlayer | None = None
67
+ ptt: PushToTalkGate | None = None
68
+ device_hints: list[str] = []
69
+
70
+ if text:
71
+ return False, None, None, None, 0
72
+
73
+ try:
74
+ player = AudioPlayer(
75
+ sample_rate=_parse_sample_rate(input_format),
76
+ channels=1,
77
+ format_name=input_format,
78
+ )
79
+ except AudioIOError as exc:
80
+ device_hints.append(f"Audio output unavailable: {exc}")
81
+
82
+ try:
83
+ recorder = AudioRecorder(
84
+ sample_rate=_parse_sample_rate(input_format),
85
+ channels=1,
86
+ chunk_ms=chunk_ms,
87
+ format_name=input_format,
88
+ )
89
+ except AudioIOError as exc:
90
+ if player is None:
91
+ print(f"Audio setup failed: {exc}", file=sys.stderr)
92
+ print(
93
+ "Tip: install PortAudio (brew install portaudio) and sync with "
94
+ "'uv sync --extra cli-realtime', or use --text for text-only mode.",
95
+ file=sys.stderr,
96
+ )
97
+ return False, None, None, None, 1
98
+ device_hints.append(f"Microphone unavailable: {exc} Continuing with text input.")
99
+ audio_input_enabled = False
100
+
101
+ if audio_input_enabled:
102
+ try:
103
+ ptt = PushToTalkGate(key_name=ptt_key)
104
+ except PushToTalkError as exc:
105
+ print(f"Push-to-talk unavailable: {exc}", file=sys.stderr)
106
+ print(
107
+ "Tip: sync with 'uv sync --extra cli-realtime'. "
108
+ "On macOS, grant Accessibility permission to your terminal app. "
109
+ "Space in an empty composer toggles in-TUI PTT.",
110
+ file=sys.stderr,
111
+ )
112
+ # Continue without global PTT — in-TUI Space still works when TTY.
113
+ ptt = None
114
+
115
+ for hint in device_hints:
116
+ print(hint, file=sys.stderr)
117
+
118
+ return audio_input_enabled and recorder is not None, recorder, player, ptt, 0
119
+
120
+
121
+ def run_talk_session(
122
+ *,
123
+ gateway_url: str | None = None,
124
+ session_id: str | None = None,
125
+ text: bool = False,
126
+ ptt_key: str = "cmd",
127
+ start_gateway: bool = True,
128
+ input_format: str = "pcm_s16le_24khz_mono",
129
+ chunk_ms: int = 200,
130
+ verbose: bool = False,
131
+ ) -> int:
132
+ """Run a realtime talk session via ``RealtimeSessionController``. Returns exit code."""
133
+ _setup_logging(verbose=verbose)
134
+ get_realtime_config()
135
+ if not session_id:
136
+ session_id = _generate_session_id()
137
+ if not gateway_url:
138
+ gateway_url = default_gateway_ws_url()
139
+
140
+ from monkeybot_cli.realtime.talk_ui import run_talk_ui_session
141
+
142
+ audio_enabled, recorder, player, ptt, setup_code = _setup_audio_devices(
143
+ text=text,
144
+ input_format=input_format,
145
+ chunk_ms=chunk_ms,
146
+ ptt_key=ptt_key,
147
+ )
148
+ if setup_code != 0:
149
+ return setup_code
150
+
151
+ try:
152
+ return run_talk_ui_session(
153
+ gateway_url=gateway_url,
154
+ session_id=session_id,
155
+ start_gateway=start_gateway,
156
+ verbose=verbose,
157
+ audio_enabled=audio_enabled,
158
+ audio_recorder=recorder,
159
+ audio_player=player,
160
+ push_to_talk=ptt,
161
+ )
162
+ finally:
163
+ if recorder is not None:
164
+ recorder.close()
165
+ if player is not None:
166
+ player.close()
167
+
168
+
169
+ @app.command("talk")
170
+ def talk(
171
+ gateway_url: Annotated[
172
+ str,
173
+ typer.Option(
174
+ "--gateway-url",
175
+ help="Base URL of the MonkeyBot realtime gateway, e.g. ws://127.0.0.1:8080",
176
+ envvar="MONKEYBOT_GATEWAY_URL",
177
+ ),
178
+ ] = "",
179
+ session_id: Annotated[
180
+ str | None,
181
+ typer.Option(
182
+ "--session-id",
183
+ help="Session ID to connect to; generated if not provided",
184
+ envvar="MONKEYBOT_SESSION_ID",
185
+ ),
186
+ ] = None,
187
+ text: Annotated[
188
+ bool,
189
+ typer.Option(
190
+ "--text/--no-text",
191
+ help="Text-only input (no microphone). Audio output is still used when available.",
192
+ ),
193
+ ] = False,
194
+ ptt_key: Annotated[
195
+ str,
196
+ typer.Option(
197
+ "--ptt-key",
198
+ help="Push-to-talk key to hold while speaking: cmd, alt, ctrl, or space",
199
+ ),
200
+ ] = "cmd",
201
+ start_gateway: Annotated[
202
+ bool,
203
+ typer.Option(
204
+ "--start-gateway/--no-start-gateway",
205
+ help="Start a local realtime gateway if one is not already reachable",
206
+ ),
207
+ ] = True,
208
+ input_format: Annotated[
209
+ str,
210
+ typer.Option(
211
+ "--input-format",
212
+ help="Audio format, e.g. pcm_s16le_24khz_mono",
213
+ ),
214
+ ] = "pcm_s16le_24khz_mono",
215
+ chunk_ms: Annotated[
216
+ int,
217
+ typer.Option("--chunk-ms", help="Audio chunk size in milliseconds"),
218
+ ] = 200,
219
+ verbose: Annotated[
220
+ bool,
221
+ typer.Option(
222
+ "--verbose/--no-verbose",
223
+ help="Enable debug logging for audio chunks and gateway events",
224
+ ),
225
+ ] = False,
226
+ ) -> None:
227
+ """Talk with a MonkeyBot realtime agent."""
228
+ code = run_talk_session(
229
+ gateway_url=gateway_url or None,
230
+ session_id=session_id,
231
+ text=text,
232
+ ptt_key=ptt_key,
233
+ start_gateway=start_gateway,
234
+ input_format=input_format,
235
+ chunk_ms=chunk_ms,
236
+ verbose=verbose,
237
+ )
238
+ if code != 0:
239
+ raise typer.Exit(code)
240
+
241
+
242
+ @app.command()
243
+ def version() -> None:
244
+ """Show MonkeyBot version."""
245
+ from importlib.metadata import version as get_version
246
+
247
+ typer.echo(get_version("monkeybot"))
248
+
249
+
250
+ def main() -> None:
251
+ """Console entrypoint for realtime helpers (prefer ``monkeybot`` from monkeybot-cli)."""
252
+ app()
253
+
254
+
255
+ if __name__ == "__main__":
256
+ main()