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.
- monkeybot_cli/__init__.py +3 -0
- monkeybot_cli/chat_renderer.py +87 -0
- monkeybot_cli/chat_session.py +911 -0
- monkeybot_cli/chat_status_bar.py +205 -0
- monkeybot_cli/chat_theme.py +91 -0
- monkeybot_cli/chat_tool_display.py +334 -0
- monkeybot_cli/chat_tui.py +1491 -0
- monkeybot_cli/chat_tui_widgets.py +996 -0
- monkeybot_cli/commands/__init__.py +1 -0
- monkeybot_cli/commands/chat.py +817 -0
- monkeybot_cli/commands/doctor.py +293 -0
- monkeybot_cli/commands/loop.py +207 -0
- monkeybot_cli/commands/new.py +207 -0
- monkeybot_cli/commands/run_cmd.py +41 -0
- monkeybot_cli/commands/talk.py +102 -0
- monkeybot_cli/commands/validate.py +385 -0
- monkeybot_cli/compat.py +7 -0
- monkeybot_cli/config_resolve.py +55 -0
- monkeybot_cli/exit_commands.py +13 -0
- monkeybot_cli/extras_catalog.py +95 -0
- monkeybot_cli/gateway_health.py +34 -0
- monkeybot_cli/main.py +38 -0
- monkeybot_cli/opensandbox_lifecycle.py +314 -0
- monkeybot_cli/output.py +110 -0
- monkeybot_cli/providers.py +112 -0
- monkeybot_cli/realtime/__init__.py +13 -0
- monkeybot_cli/realtime/audio_io.py +147 -0
- monkeybot_cli/realtime/client.py +17 -0
- monkeybot_cli/realtime/gateway_manager.py +142 -0
- monkeybot_cli/realtime/push_to_talk.py +128 -0
- monkeybot_cli/realtime/session.py +256 -0
- monkeybot_cli/realtime/session_controller.py +501 -0
- monkeybot_cli/realtime/talk_ui.py +243 -0
- monkeybot_cli/realtime/wire_encode.py +39 -0
- monkeybot_cli/runtime_python.py +91 -0
- monkeybot_cli/scaffold.py +287 -0
- monkeybot_cli/scaffold_defaults/AGENT.md +56 -0
- monkeybot_cli/scaffold_defaults/__init__.py +1 -0
- monkeybot_cli/scaffold_defaults/command_allowlist.yaml +57 -0
- monkeybot_cli/scaffold_defaults/env.example +35 -0
- monkeybot_cli/scaffold_defaults/mcp.json +49 -0
- monkeybot_cli/scaffold_defaults/monkeybot.example.yaml +191 -0
- monkeybot_cli/scaffold_defaults/otel-collector.example.yaml +57 -0
- monkeybot_cli/scaffold_defaults/permissions.yaml +32 -0
- monkeybot_cli/scaffold_defaults/setup-workspace.sh +24 -0
- monkeybot_cli/session_controller.py +7 -0
- monkeybot_cli/terminal_markdown.py +48 -0
- monkeybot_cli-0.2.1.dist-info/METADATA +10 -0
- monkeybot_cli-0.2.1.dist-info/RECORD +51 -0
- monkeybot_cli-0.2.1.dist-info/WHEEL +4 -0
- monkeybot_cli-0.2.1.dist-info/entry_points.txt +2 -0
|
@@ -0,0 +1,243 @@
|
|
|
1
|
+
"""Wire ``monkeybot talk`` (text or audio) through the shared Chat TUI / plain renderer."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
import contextlib
|
|
7
|
+
import signal
|
|
8
|
+
import subprocess
|
|
9
|
+
import sys
|
|
10
|
+
import tempfile
|
|
11
|
+
import urllib.parse
|
|
12
|
+
import uuid
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
from typing import Any, NamedTuple, TextIO
|
|
15
|
+
|
|
16
|
+
from monkeybot_cli.chat_tui import is_exit_command, run_chat_tui
|
|
17
|
+
from monkeybot_cli.commands.chat import (
|
|
18
|
+
_DIM,
|
|
19
|
+
_RESET,
|
|
20
|
+
_USER_PROMPT,
|
|
21
|
+
_PlainRenderer,
|
|
22
|
+
_read_line,
|
|
23
|
+
use_textual_tui,
|
|
24
|
+
)
|
|
25
|
+
from monkeybot_cli.config_resolve import (
|
|
26
|
+
load_agent_dotenv,
|
|
27
|
+
load_config_doc,
|
|
28
|
+
resolve_agent_root,
|
|
29
|
+
resolve_config,
|
|
30
|
+
)
|
|
31
|
+
from monkeybot_cli.gateway_health import health_ok, wait_for_health
|
|
32
|
+
from monkeybot_cli.realtime.session_controller import RealtimeSessionController
|
|
33
|
+
from monkeybot_cli.runtime_python import (
|
|
34
|
+
COMBINED_GATEWAY_MODULE,
|
|
35
|
+
DEFAULT_PORT,
|
|
36
|
+
gateway_argv,
|
|
37
|
+
resolve_runtime_python,
|
|
38
|
+
)
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
class _SpawnedGateway(NamedTuple):
|
|
42
|
+
proc: subprocess.Popen[str]
|
|
43
|
+
log_path: Path
|
|
44
|
+
log_file: TextIO
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def _ws_to_http_base(gateway_url: str) -> str:
|
|
48
|
+
parsed = urllib.parse.urlparse(gateway_url)
|
|
49
|
+
host = parsed.hostname or "127.0.0.1"
|
|
50
|
+
port = parsed.port or DEFAULT_PORT
|
|
51
|
+
scheme = "https" if parsed.scheme == "wss" else "http"
|
|
52
|
+
return f"{scheme}://{host}:{port}"
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def _model_fields(config_path: Path | None) -> tuple[str, str]:
|
|
56
|
+
provider, model = "?", "?"
|
|
57
|
+
if config_path is None:
|
|
58
|
+
return provider, model
|
|
59
|
+
_, doc = load_config_doc(str(config_path))
|
|
60
|
+
model_cfg = doc.get("model") if isinstance(doc.get("model"), dict) else {}
|
|
61
|
+
provider = str(model_cfg.get("provider") or provider).strip() or provider
|
|
62
|
+
model = str(model_cfg.get("name") or model).strip() or model
|
|
63
|
+
return provider, model
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def _url_is_local(url: str) -> bool:
|
|
67
|
+
host = (urllib.parse.urlparse(url).hostname or "").lower()
|
|
68
|
+
return host in {"localhost", "127.0.0.1", "::1"}
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def _spawn_combined_gateway(
|
|
72
|
+
config_path: Path | None, agent_root: Path, port: int
|
|
73
|
+
) -> _SpawnedGateway:
|
|
74
|
+
import os
|
|
75
|
+
|
|
76
|
+
env = os.environ.copy()
|
|
77
|
+
if config_path is not None:
|
|
78
|
+
env["MONKEYBOT_CONFIG"] = str(config_path)
|
|
79
|
+
env["PORT"] = str(port)
|
|
80
|
+
env.setdefault("LOG_LEVEL", "error")
|
|
81
|
+
log_file = tempfile.NamedTemporaryFile(
|
|
82
|
+
mode="w+",
|
|
83
|
+
prefix="monkeybot-gateway-",
|
|
84
|
+
suffix=".log",
|
|
85
|
+
delete=False,
|
|
86
|
+
encoding="utf-8",
|
|
87
|
+
errors="replace",
|
|
88
|
+
)
|
|
89
|
+
proc = subprocess.Popen(
|
|
90
|
+
gateway_argv(resolve_runtime_python(agent_root), module=COMBINED_GATEWAY_MODULE),
|
|
91
|
+
env=env,
|
|
92
|
+
cwd=agent_root,
|
|
93
|
+
stdout=subprocess.DEVNULL,
|
|
94
|
+
stderr=log_file,
|
|
95
|
+
)
|
|
96
|
+
return _SpawnedGateway(proc=proc, log_path=Path(log_file.name), log_file=log_file)
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def _cleanup_gateway(spawned: _SpawnedGateway | None) -> None:
|
|
100
|
+
if spawned is None:
|
|
101
|
+
return
|
|
102
|
+
if spawned.proc.poll() is None:
|
|
103
|
+
spawned.proc.kill()
|
|
104
|
+
with contextlib.suppress(subprocess.TimeoutExpired):
|
|
105
|
+
spawned.proc.wait(timeout=1)
|
|
106
|
+
with contextlib.suppress(OSError):
|
|
107
|
+
if not spawned.log_file.closed:
|
|
108
|
+
spawned.log_file.close()
|
|
109
|
+
with contextlib.suppress(OSError):
|
|
110
|
+
spawned.log_path.unlink(missing_ok=True)
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
async def _plain_talk_session(
|
|
114
|
+
*,
|
|
115
|
+
controller: RealtimeSessionController,
|
|
116
|
+
spawned_gateway: bool,
|
|
117
|
+
) -> int:
|
|
118
|
+
interrupt = asyncio.Event()
|
|
119
|
+
loop = asyncio.get_running_loop()
|
|
120
|
+
with contextlib.suppress(NotImplementedError):
|
|
121
|
+
loop.add_signal_handler(signal.SIGINT, interrupt.set)
|
|
122
|
+
|
|
123
|
+
renderer = _PlainRenderer(animations_enabled=True)
|
|
124
|
+
renderer.start_io_worker()
|
|
125
|
+
controller.set_emit(lambda e: renderer.on_event(e, controller))
|
|
126
|
+
|
|
127
|
+
try:
|
|
128
|
+
await controller.connect()
|
|
129
|
+
except RuntimeError as exc:
|
|
130
|
+
print(str(exc), file=sys.stderr)
|
|
131
|
+
await renderer.stop_io_worker()
|
|
132
|
+
return 1
|
|
133
|
+
|
|
134
|
+
hint = "Type /bye to exit"
|
|
135
|
+
if spawned_gateway:
|
|
136
|
+
hint += " (stops the gateway)"
|
|
137
|
+
print(f"{_DIM}{hint}. Ctrl-C also exits.{_RESET}\n")
|
|
138
|
+
|
|
139
|
+
try:
|
|
140
|
+
while not interrupt.is_set() and controller.stream_alive:
|
|
141
|
+
user_line = await _read_line(_USER_PROMPT, interrupt)
|
|
142
|
+
if user_line is None or interrupt.is_set():
|
|
143
|
+
break
|
|
144
|
+
if not user_line.strip():
|
|
145
|
+
continue
|
|
146
|
+
if is_exit_command(user_line):
|
|
147
|
+
if spawned_gateway:
|
|
148
|
+
print(f"\n{_DIM}Goodbye — shutting down gateway…{_RESET}")
|
|
149
|
+
else:
|
|
150
|
+
print(f"\n{_DIM}Goodbye.{_RESET}")
|
|
151
|
+
break
|
|
152
|
+
await controller.submit(user_line)
|
|
153
|
+
finally:
|
|
154
|
+
await controller.close()
|
|
155
|
+
await renderer.stop_io_worker()
|
|
156
|
+
return 1 if controller.stream_error else 0
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
def run_talk_ui_session(
|
|
160
|
+
*,
|
|
161
|
+
gateway_url: str,
|
|
162
|
+
session_id: str | None = None,
|
|
163
|
+
start_gateway: bool = True,
|
|
164
|
+
verbose: bool = False,
|
|
165
|
+
audio_enabled: bool = False,
|
|
166
|
+
audio_recorder: Any | None = None,
|
|
167
|
+
audio_player: Any | None = None,
|
|
168
|
+
push_to_talk: Any | None = None,
|
|
169
|
+
) -> int:
|
|
170
|
+
"""Run talk via ChatApp (TTY) or plain renderer, with optional audio I/O."""
|
|
171
|
+
cwd = Path.cwd()
|
|
172
|
+
config_path = resolve_config(None, cwd=cwd)
|
|
173
|
+
load_agent_dotenv(cwd=cwd, config_path=config_path)
|
|
174
|
+
agent_root = resolve_agent_root(cwd=cwd, config_path=config_path)
|
|
175
|
+
provider, model = _model_fields(config_path)
|
|
176
|
+
base = _ws_to_http_base(gateway_url)
|
|
177
|
+
sid = session_id or uuid.uuid4().hex
|
|
178
|
+
parsed = urllib.parse.urlparse(gateway_url)
|
|
179
|
+
port = parsed.port or DEFAULT_PORT
|
|
180
|
+
|
|
181
|
+
spawned: _SpawnedGateway | None = None
|
|
182
|
+
if start_gateway and _url_is_local(gateway_url):
|
|
183
|
+
if not health_ok(base):
|
|
184
|
+
if config_path is None:
|
|
185
|
+
print(
|
|
186
|
+
"Could not find monkeybot_config/monkeybot.yaml to start the gateway. "
|
|
187
|
+
"Run the command from a MonkeyBot workspace or start the gateway manually.",
|
|
188
|
+
file=sys.stderr,
|
|
189
|
+
)
|
|
190
|
+
return 1
|
|
191
|
+
spawned = _spawn_combined_gateway(config_path, agent_root, port)
|
|
192
|
+
if not wait_for_health(base, spawned.proc):
|
|
193
|
+
print("Gateway failed to start.", file=sys.stderr)
|
|
194
|
+
_cleanup_gateway(spawned)
|
|
195
|
+
return 1
|
|
196
|
+
|
|
197
|
+
controller = RealtimeSessionController(
|
|
198
|
+
gateway_url=gateway_url,
|
|
199
|
+
session_id=sid,
|
|
200
|
+
verbose=verbose,
|
|
201
|
+
audio_enabled=audio_enabled,
|
|
202
|
+
audio_recorder=audio_recorder,
|
|
203
|
+
audio_player=audio_player,
|
|
204
|
+
push_to_talk=push_to_talk,
|
|
205
|
+
)
|
|
206
|
+
|
|
207
|
+
try:
|
|
208
|
+
if use_textual_tui():
|
|
209
|
+
return run_chat_tui(
|
|
210
|
+
base=base,
|
|
211
|
+
agent_root=agent_root,
|
|
212
|
+
provider=provider,
|
|
213
|
+
model=model,
|
|
214
|
+
spawned_gateway=spawned is not None,
|
|
215
|
+
verbose=verbose,
|
|
216
|
+
controller=controller,
|
|
217
|
+
)
|
|
218
|
+
return asyncio.run(
|
|
219
|
+
_plain_talk_session(controller=controller, spawned_gateway=spawned is not None)
|
|
220
|
+
)
|
|
221
|
+
except KeyboardInterrupt:
|
|
222
|
+
sys.stdout.write("\n")
|
|
223
|
+
sys.stdout.flush()
|
|
224
|
+
return 130
|
|
225
|
+
finally:
|
|
226
|
+
_cleanup_gateway(spawned)
|
|
227
|
+
|
|
228
|
+
|
|
229
|
+
def run_talk_text_session(
|
|
230
|
+
*,
|
|
231
|
+
gateway_url: str,
|
|
232
|
+
session_id: str | None = None,
|
|
233
|
+
start_gateway: bool = True,
|
|
234
|
+
verbose: bool = False,
|
|
235
|
+
) -> int:
|
|
236
|
+
"""Backward-compatible text-only entry."""
|
|
237
|
+
return run_talk_ui_session(
|
|
238
|
+
gateway_url=gateway_url,
|
|
239
|
+
session_id=session_id,
|
|
240
|
+
start_gateway=start_gateway,
|
|
241
|
+
verbose=verbose,
|
|
242
|
+
audio_enabled=False,
|
|
243
|
+
)
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
"""Encode client realtime frames to JSON for the WebSocket wire."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
from monkeybot.gateway.realtime.wire import (
|
|
9
|
+
ClientCloseFrame,
|
|
10
|
+
ClientElicitationResponseFrame,
|
|
11
|
+
ClientTextFrame,
|
|
12
|
+
ClientToolConfirmationResponseFrame,
|
|
13
|
+
)
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def encode_client_frame(frame: Any) -> str:
|
|
17
|
+
"""Encode a client control frame to JSON text."""
|
|
18
|
+
payload: dict[str, Any] = {"kind": frame.kind}
|
|
19
|
+
if isinstance(frame, ClientCloseFrame):
|
|
20
|
+
payload["reason"] = frame.reason
|
|
21
|
+
elif isinstance(frame, ClientTextFrame):
|
|
22
|
+
payload["text"] = frame.text
|
|
23
|
+
elif isinstance(frame, ClientToolConfirmationResponseFrame):
|
|
24
|
+
payload.update(
|
|
25
|
+
{
|
|
26
|
+
"tool_call_id": frame.tool_call_id,
|
|
27
|
+
"approved": frame.approved,
|
|
28
|
+
"reason": frame.reason,
|
|
29
|
+
}
|
|
30
|
+
)
|
|
31
|
+
elif isinstance(frame, ClientElicitationResponseFrame):
|
|
32
|
+
payload.update(
|
|
33
|
+
{
|
|
34
|
+
"elicitation_id": frame.elicitation_id,
|
|
35
|
+
"user_data": frame.user_data,
|
|
36
|
+
"cancelled": frame.cancelled,
|
|
37
|
+
}
|
|
38
|
+
)
|
|
39
|
+
return json.dumps(payload, ensure_ascii=False, separators=(",", ":"))
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
"""Resolve the Python interpreter that should run the gateway.
|
|
2
|
+
|
|
3
|
+
The CLI is intentionally thin: it depends only on base ``monkeybot`` and does **not**
|
|
4
|
+
pull in provider/storage extras (``bedrock``, ``postgres``, …). Those extras are
|
|
5
|
+
declared on the *agent project* (e.g. ``pr-review-agent/pyproject.toml`` lists
|
|
6
|
+
``monkeybot[bedrock,postgres]``). To honor that, the gateway must be spawned from
|
|
7
|
+
the agent project's interpreter rather than the CLI's own ``sys.executable``.
|
|
8
|
+
|
|
9
|
+
Resolution order for an agent root:
|
|
10
|
+
|
|
11
|
+
1. ``<root>/.venv/bin/python`` (or the Windows variant) — direct, no subprocess overhead.
|
|
12
|
+
2. ``uv run python`` — when ``<root>/pyproject.toml`` exists but no ``.venv``.
|
|
13
|
+
3. ``sys.executable`` — legacy / config-only trees (just ``monkeybot_config/``, no
|
|
14
|
+
``pyproject.toml``). In this case extras must be installed in the CLI env.
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
from __future__ import annotations
|
|
18
|
+
|
|
19
|
+
import sys
|
|
20
|
+
from dataclasses import dataclass
|
|
21
|
+
from pathlib import Path
|
|
22
|
+
|
|
23
|
+
DEFAULT_PORT = 8080
|
|
24
|
+
SSE_GATEWAY_MODULE = "monkeybot.gateway.main"
|
|
25
|
+
COMBINED_GATEWAY_MODULE = "monkeybot.gateway.realtime_main"
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def _venv_python(agent_root: Path) -> Path | None:
|
|
29
|
+
"""Return the project venv interpreter if it exists, else ``None``."""
|
|
30
|
+
venv = agent_root / ".venv"
|
|
31
|
+
for candidate in (venv / "bin" / "python", venv / "Scripts" / "python.exe"):
|
|
32
|
+
if candidate.is_file():
|
|
33
|
+
return candidate
|
|
34
|
+
return None
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
@dataclass(frozen=True)
|
|
38
|
+
class RuntimePython:
|
|
39
|
+
"""Resolved Python runtime for an agent project.
|
|
40
|
+
|
|
41
|
+
``argv`` is the prefix to prepend to ``-m monkeybot.gateway.*`` or
|
|
42
|
+
``-c "…"`` doctor probes. ``source`` is for diagnostics/remediation text.
|
|
43
|
+
``agent_root`` is set for ``uv run`` resolution (probes need the project cwd).
|
|
44
|
+
"""
|
|
45
|
+
|
|
46
|
+
argv: list[str]
|
|
47
|
+
source: str # "venv" | "uv" | "cli"
|
|
48
|
+
agent_root: Path | None = None
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def resolve_runtime_python(agent_root: Path) -> RuntimePython:
|
|
52
|
+
"""Resolve the interpreter that should run the gateway for ``agent_root``."""
|
|
53
|
+
venv_py = _venv_python(agent_root)
|
|
54
|
+
if venv_py is not None:
|
|
55
|
+
return RuntimePython([str(venv_py)], "venv", agent_root)
|
|
56
|
+
if (agent_root / "pyproject.toml").is_file():
|
|
57
|
+
return RuntimePython(["uv", "run", "python"], "uv", agent_root)
|
|
58
|
+
return RuntimePython([sys.executable], "cli", agent_root)
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def gateway_argv(
|
|
62
|
+
runtime: RuntimePython,
|
|
63
|
+
*,
|
|
64
|
+
module: str = COMBINED_GATEWAY_MODULE,
|
|
65
|
+
) -> list[str]:
|
|
66
|
+
"""Full argv to launch a gateway module under ``runtime``.
|
|
67
|
+
|
|
68
|
+
CLI auto-start defaults to the combined SSE+WebSocket entrypoint
|
|
69
|
+
(``realtime_main``) so ``chat`` and ``talk`` share one process/port.
|
|
70
|
+
"""
|
|
71
|
+
return [*runtime.argv, "-m", module]
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def run_probe(runtime: RuntimePython, code: str, *, timeout: float = 15.0) -> bool:
|
|
75
|
+
"""Run ``python -c code`` under ``runtime`` and return True on exit 0.
|
|
76
|
+
|
|
77
|
+
Used by ``doctor`` to verify extras/imports in the *gateway* interpreter
|
|
78
|
+
rather than the CLI's own process.
|
|
79
|
+
"""
|
|
80
|
+
import subprocess
|
|
81
|
+
|
|
82
|
+
kwargs: dict[str, object] = {}
|
|
83
|
+
if runtime.source == "uv" and runtime.agent_root is not None:
|
|
84
|
+
kwargs["cwd"] = str(runtime.agent_root)
|
|
85
|
+
proc = subprocess.run(
|
|
86
|
+
[*runtime.argv, "-c", code],
|
|
87
|
+
capture_output=True,
|
|
88
|
+
timeout=timeout,
|
|
89
|
+
**kwargs,
|
|
90
|
+
)
|
|
91
|
+
return proc.returncode == 0
|
|
@@ -0,0 +1,287 @@
|
|
|
1
|
+
"""Workspace scaffolding from CLI-packaged ``scaffold_defaults``."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import re
|
|
6
|
+
import shutil
|
|
7
|
+
import stat
|
|
8
|
+
from importlib import resources
|
|
9
|
+
from importlib.resources.abc import Traversable
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
from typing import Final
|
|
12
|
+
|
|
13
|
+
import yaml
|
|
14
|
+
|
|
15
|
+
from monkeybot_cli.compat import COMPATIBLE_CORE_RANGE
|
|
16
|
+
from monkeybot_cli.extras_catalog import normalize_extra_token, provider_extra_name
|
|
17
|
+
|
|
18
|
+
_DEFAULTS_PKG: Final = "monkeybot_cli.scaffold_defaults"
|
|
19
|
+
# Matches packaged monkeybot.example.yaml default when ``--provider`` is omitted.
|
|
20
|
+
_DEFAULT_PROVIDER: Final = "gemini"
|
|
21
|
+
|
|
22
|
+
# (filename in packaged defaults, output name under <dest>/monkeybot_config/)
|
|
23
|
+
_CONFIG_BUNDLE: Final[tuple[tuple[str, str], ...]] = (
|
|
24
|
+
("monkeybot.example.yaml", "monkeybot.example.yaml"),
|
|
25
|
+
("mcp.json", "mcp.json"),
|
|
26
|
+
("command_allowlist.yaml", "command_allowlist.yaml"),
|
|
27
|
+
("permissions.yaml", "permissions.yaml"),
|
|
28
|
+
("AGENT.md", "AGENT.md"),
|
|
29
|
+
("env.example", "env.example"),
|
|
30
|
+
("otel-collector.example.yaml", "otel-collector.example.yaml"),
|
|
31
|
+
)
|
|
32
|
+
|
|
33
|
+
_MEMORY_INDEX: Final = (
|
|
34
|
+
"# Memory index\n\nAdd sections here or let memory tools populate this file.\n"
|
|
35
|
+
)
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def _install_file(dest: Path, src: Traversable, *, force: bool) -> str:
|
|
39
|
+
# ponytail: read_bytes() avoids resources.as_file() temp-file lifetime issue in zip distributions
|
|
40
|
+
if dest.exists() and not force:
|
|
41
|
+
return "skipped"
|
|
42
|
+
existed = dest.exists()
|
|
43
|
+
dest.write_bytes(src.read_bytes())
|
|
44
|
+
return "overwritten" if existed else "created"
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def install_config_bundle(cfg_dir: Path, *, force: bool) -> list[str]:
|
|
48
|
+
"""Copy packaged defaults into ``cfg_dir``; return report lines."""
|
|
49
|
+
cfg_dir.mkdir(parents=True, exist_ok=True)
|
|
50
|
+
lines: list[str] = []
|
|
51
|
+
for src_name, dest_name in _CONFIG_BUNDLE:
|
|
52
|
+
status = _install_file(
|
|
53
|
+
cfg_dir / dest_name,
|
|
54
|
+
resources.files(_DEFAULTS_PKG) / src_name,
|
|
55
|
+
force=force,
|
|
56
|
+
)
|
|
57
|
+
lines.append(f" monkeybot_config/{dest_name}: {status}")
|
|
58
|
+
return lines
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def write_active_config(
|
|
62
|
+
cfg_dir: Path,
|
|
63
|
+
*,
|
|
64
|
+
provider: str | None = None,
|
|
65
|
+
model: str | None = None,
|
|
66
|
+
force: bool = False,
|
|
67
|
+
) -> str:
|
|
68
|
+
"""Create or update ``monkeybot.yaml`` from the packaged example."""
|
|
69
|
+
active = cfg_dir / "monkeybot.yaml"
|
|
70
|
+
if active.exists() and not force:
|
|
71
|
+
if provider or model:
|
|
72
|
+
doc = yaml.safe_load(active.read_text(encoding="utf-8")) or {}
|
|
73
|
+
if not isinstance(doc, dict):
|
|
74
|
+
doc = {}
|
|
75
|
+
model_sec = doc.setdefault("model", {})
|
|
76
|
+
if isinstance(model_sec, dict):
|
|
77
|
+
if provider:
|
|
78
|
+
model_sec["provider"] = provider
|
|
79
|
+
if model:
|
|
80
|
+
model_sec["name"] = model
|
|
81
|
+
active.write_text(yaml.safe_dump(doc, sort_keys=False), encoding="utf-8")
|
|
82
|
+
return "updated (provider/model)"
|
|
83
|
+
return "skipped"
|
|
84
|
+
existed = active.exists()
|
|
85
|
+
example_text = (resources.files(_DEFAULTS_PKG) / "monkeybot.example.yaml").read_text(
|
|
86
|
+
encoding="utf-8"
|
|
87
|
+
)
|
|
88
|
+
active.write_text(example_text, encoding="utf-8")
|
|
89
|
+
if provider or model:
|
|
90
|
+
doc = yaml.safe_load(active.read_text(encoding="utf-8")) or {}
|
|
91
|
+
if isinstance(doc, dict):
|
|
92
|
+
model_sec = doc.setdefault("model", {})
|
|
93
|
+
if isinstance(model_sec, dict):
|
|
94
|
+
if provider:
|
|
95
|
+
model_sec["provider"] = provider
|
|
96
|
+
if model:
|
|
97
|
+
model_sec["name"] = model
|
|
98
|
+
active.write_text(yaml.safe_dump(doc, sort_keys=False), encoding="utf-8")
|
|
99
|
+
if existed:
|
|
100
|
+
return "overwritten"
|
|
101
|
+
if provider or model:
|
|
102
|
+
return "created"
|
|
103
|
+
return "created (from monkeybot.example.yaml)"
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def ensure_memory(dest: Path, *, force: bool) -> list[str]:
|
|
107
|
+
memory = dest / "data" / "memory"
|
|
108
|
+
memory.mkdir(parents=True, exist_ok=True)
|
|
109
|
+
idx = memory / "INDEX.md"
|
|
110
|
+
if not idx.exists() or force:
|
|
111
|
+
existed = idx.exists()
|
|
112
|
+
idx.write_text(_MEMORY_INDEX, encoding="utf-8")
|
|
113
|
+
return [f" data/memory/INDEX.md: {'overwritten' if existed else 'created'}"]
|
|
114
|
+
return [" data/memory/INDEX.md: skipped"]
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def ensure_workspace(dest: Path, *, force: bool) -> list[str]:
|
|
118
|
+
"""Create workspace/ sandbox and workspace/skills -> ../skills symlink."""
|
|
119
|
+
lines: list[str] = []
|
|
120
|
+
workspace = dest / "workspace"
|
|
121
|
+
workspace.mkdir(parents=True, exist_ok=True)
|
|
122
|
+
gitkeep = workspace / ".gitkeep"
|
|
123
|
+
if not gitkeep.exists() or force:
|
|
124
|
+
gitkeep.touch(exist_ok=True)
|
|
125
|
+
lines.append(
|
|
126
|
+
f" workspace/.gitkeep: {'overwritten' if force and gitkeep.exists() else 'created'}"
|
|
127
|
+
)
|
|
128
|
+
else:
|
|
129
|
+
lines.append(" workspace/.gitkeep: skipped")
|
|
130
|
+
|
|
131
|
+
dest.joinpath("skills").mkdir(parents=True, exist_ok=True)
|
|
132
|
+
link = workspace / "skills"
|
|
133
|
+
expected = (dest / "skills").resolve()
|
|
134
|
+
|
|
135
|
+
if link.is_symlink():
|
|
136
|
+
if link.resolve() == expected:
|
|
137
|
+
lines.append(" workspace/skills: skipped (symlink ok)")
|
|
138
|
+
return lines
|
|
139
|
+
link.unlink()
|
|
140
|
+
|
|
141
|
+
if link.exists() and not link.is_symlink():
|
|
142
|
+
if not force:
|
|
143
|
+
lines.append(" workspace/skills: skipped (path exists)")
|
|
144
|
+
return lines
|
|
145
|
+
if link.is_dir():
|
|
146
|
+
shutil.rmtree(link)
|
|
147
|
+
else:
|
|
148
|
+
link.unlink()
|
|
149
|
+
|
|
150
|
+
try:
|
|
151
|
+
link.symlink_to("../skills", target_is_directory=True)
|
|
152
|
+
lines.append(" workspace/skills: symlink -> ../skills")
|
|
153
|
+
except OSError:
|
|
154
|
+
readme = workspace / "SKILLS_README.txt"
|
|
155
|
+
readme.write_text(
|
|
156
|
+
"Could not create workspace/skills symlink on this platform.\n"
|
|
157
|
+
"Run: bash scripts/setup-workspace.sh\n"
|
|
158
|
+
"Or copy/symlink skills/ into workspace/skills manually.\n",
|
|
159
|
+
encoding="utf-8",
|
|
160
|
+
)
|
|
161
|
+
lines.append(" workspace/skills: symlink failed (see workspace/SKILLS_README.txt)")
|
|
162
|
+
|
|
163
|
+
return lines
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
def install_setup_script(dest: Path, *, force: bool) -> str:
|
|
167
|
+
scripts = dest / "scripts"
|
|
168
|
+
scripts.mkdir(parents=True, exist_ok=True)
|
|
169
|
+
dest_script = scripts / "setup-workspace.sh"
|
|
170
|
+
if dest_script.exists() and not force:
|
|
171
|
+
return "skipped"
|
|
172
|
+
existed = dest_script.exists()
|
|
173
|
+
dest_script.write_bytes(
|
|
174
|
+
(resources.files(_DEFAULTS_PKG) / "setup-workspace.sh").read_bytes()
|
|
175
|
+
)
|
|
176
|
+
dest_script.chmod(dest_script.stat().st_mode | stat.S_IXUSR | stat.S_IXGRP | stat.S_IXOTH)
|
|
177
|
+
return "overwritten" if existed else "created"
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
def install_env_example(dest: Path, *, force: bool) -> str:
|
|
181
|
+
env_example = dest / ".env.example"
|
|
182
|
+
if env_example.exists() and not force:
|
|
183
|
+
return "skipped"
|
|
184
|
+
existed = env_example.exists()
|
|
185
|
+
_install_file(env_example, resources.files(_DEFAULTS_PKG) / "env.example", force=True)
|
|
186
|
+
return "overwritten" if existed else "created"
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
def _sanitize_project_name(raw: str) -> str:
|
|
190
|
+
name = re.sub(r"[^a-z0-9._-]+", "-", raw.strip().lower()).strip("-._")
|
|
191
|
+
return name or "agent"
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
def collect_extras(
|
|
195
|
+
*,
|
|
196
|
+
provider: str | None = None,
|
|
197
|
+
extras: list[str] | None = None,
|
|
198
|
+
) -> list[str]:
|
|
199
|
+
"""Return unique package extras: primary provider first, then extras."""
|
|
200
|
+
ordered: list[str] = []
|
|
201
|
+
seen: set[str] = set()
|
|
202
|
+
|
|
203
|
+
def _add(extra: str | None) -> None:
|
|
204
|
+
if not extra or extra in seen:
|
|
205
|
+
return
|
|
206
|
+
seen.add(extra)
|
|
207
|
+
ordered.append(extra)
|
|
208
|
+
|
|
209
|
+
effective = provider if provider is not None else _DEFAULT_PROVIDER
|
|
210
|
+
_add(provider_extra_name(effective))
|
|
211
|
+
for raw in extras or ():
|
|
212
|
+
token = normalize_extra_token(raw)
|
|
213
|
+
if token is not None:
|
|
214
|
+
_add(token)
|
|
215
|
+
return ordered
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
def monkeybot_requirement(
|
|
219
|
+
*,
|
|
220
|
+
provider: str | None = None,
|
|
221
|
+
extras: list[str] | None = None,
|
|
222
|
+
) -> str:
|
|
223
|
+
"""Return a PyPI ``monkeybot`` / ``monkeybot[a,b]`` requirement string."""
|
|
224
|
+
ordered = collect_extras(provider=provider, extras=extras)
|
|
225
|
+
if ordered:
|
|
226
|
+
return f"monkeybot[{','.join(ordered)}]{COMPATIBLE_CORE_RANGE}"
|
|
227
|
+
return f"monkeybot{COMPATIBLE_CORE_RANGE}"
|
|
228
|
+
|
|
229
|
+
|
|
230
|
+
def monkeybot_dep_for_provider(provider: str | None) -> str:
|
|
231
|
+
"""Backward-compatible wrapper: provider-only requirement."""
|
|
232
|
+
return monkeybot_requirement(provider=provider)
|
|
233
|
+
|
|
234
|
+
|
|
235
|
+
def write_agent_pyproject(
|
|
236
|
+
dest: Path,
|
|
237
|
+
*,
|
|
238
|
+
provider: str | None = None,
|
|
239
|
+
extras: list[str] | None = None,
|
|
240
|
+
force: bool = False,
|
|
241
|
+
) -> str:
|
|
242
|
+
"""Write agent-project ``pyproject.toml`` with a PyPI ``monkeybot[…]`` dep."""
|
|
243
|
+
path = dest / "pyproject.toml"
|
|
244
|
+
if path.exists() and not force:
|
|
245
|
+
return "skipped"
|
|
246
|
+
existed = path.exists()
|
|
247
|
+
dep = monkeybot_requirement(provider=provider, extras=extras)
|
|
248
|
+
name = _sanitize_project_name(dest.name)
|
|
249
|
+
path.write_text(
|
|
250
|
+
(
|
|
251
|
+
"[project]\n"
|
|
252
|
+
f'name = "{name}"\n'
|
|
253
|
+
'version = "0.1.0"\n'
|
|
254
|
+
'requires-python = ">=3.11"\n'
|
|
255
|
+
"dependencies = [\n"
|
|
256
|
+
f' "{dep}",\n'
|
|
257
|
+
"]\n"
|
|
258
|
+
),
|
|
259
|
+
encoding="utf-8",
|
|
260
|
+
)
|
|
261
|
+
return "overwritten" if existed else "created"
|
|
262
|
+
|
|
263
|
+
|
|
264
|
+
def run_new(
|
|
265
|
+
*,
|
|
266
|
+
dest: Path,
|
|
267
|
+
force: bool,
|
|
268
|
+
provider: str | None = None,
|
|
269
|
+
model: str | None = None,
|
|
270
|
+
extras: list[str] | None = None,
|
|
271
|
+
) -> list[str]:
|
|
272
|
+
"""Full scaffold: config bundle, workspace, env example, and setup script."""
|
|
273
|
+
cfg_dir = dest / "monkeybot_config"
|
|
274
|
+
report = install_config_bundle(cfg_dir, force=force)
|
|
275
|
+
report.append(
|
|
276
|
+
f" monkeybot_config/monkeybot.yaml: "
|
|
277
|
+
f"{write_active_config(cfg_dir, provider=provider, model=model, force=force)}"
|
|
278
|
+
)
|
|
279
|
+
report.extend(ensure_memory(dest, force=force))
|
|
280
|
+
report.extend(ensure_workspace(dest, force=force))
|
|
281
|
+
report.append(f" scripts/setup-workspace.sh: {install_setup_script(dest, force=force)}")
|
|
282
|
+
report.append(f" .env.example: {install_env_example(dest, force=force)}")
|
|
283
|
+
report.append(
|
|
284
|
+
f" pyproject.toml: "
|
|
285
|
+
f"{write_agent_pyproject(dest, provider=provider, extras=extras, force=force)}"
|
|
286
|
+
)
|
|
287
|
+
return report
|