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,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