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,385 @@
|
|
|
1
|
+
"""monkeybot validate — check monkeybot.yaml and related paths."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import argparse
|
|
6
|
+
import json
|
|
7
|
+
import os
|
|
8
|
+
import re
|
|
9
|
+
import sqlite3
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
from typing import Any
|
|
12
|
+
|
|
13
|
+
import httpx
|
|
14
|
+
import yaml
|
|
15
|
+
from monkeybot.core.config import (
|
|
16
|
+
SUPPORTED_YAML_MODEL_PROVIDERS,
|
|
17
|
+
validate_monkeybot_yaml_doc,
|
|
18
|
+
validate_provider_env,
|
|
19
|
+
)
|
|
20
|
+
from monkeybot.core.config.runtime_env import ENV_MAP
|
|
21
|
+
from monkeybot.core.config.settings import ConfigError, normalize_model_provider
|
|
22
|
+
from monkeybot.core.config.yaml_loader import load_monkeybot_yaml_dict
|
|
23
|
+
|
|
24
|
+
from monkeybot_cli.config_resolve import load_agent_dotenv, resolve_config
|
|
25
|
+
from monkeybot_cli.output import CommandReport, check
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def _resolve_path(base: Path, rel: str) -> Path:
|
|
29
|
+
p = Path(rel)
|
|
30
|
+
if p.is_absolute():
|
|
31
|
+
return p
|
|
32
|
+
return (base / p).resolve()
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def _flatten_to_env(doc: dict[str, Any]) -> dict[str, str]:
|
|
36
|
+
out: dict[str, str] = {}
|
|
37
|
+
for (section, key), env_name in ENV_MAP.items():
|
|
38
|
+
sec = doc.get(section)
|
|
39
|
+
if not isinstance(sec, dict) or key not in sec:
|
|
40
|
+
continue
|
|
41
|
+
raw = sec[key]
|
|
42
|
+
if raw is None:
|
|
43
|
+
continue
|
|
44
|
+
if isinstance(raw, bool):
|
|
45
|
+
out[env_name] = "true" if raw else "false"
|
|
46
|
+
else:
|
|
47
|
+
out[env_name] = str(raw)
|
|
48
|
+
gcp = doc.get("gcp") if isinstance(doc.get("gcp"), dict) else {}
|
|
49
|
+
if isinstance(gcp, dict) and gcp.get("project_id"):
|
|
50
|
+
out["GCP_PROJECT_ID"] = str(gcp["project_id"])
|
|
51
|
+
return out
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def _collect_mcp_env_refs(obj: Any) -> set[str]:
|
|
55
|
+
refs: set[str] = set()
|
|
56
|
+
if isinstance(obj, str):
|
|
57
|
+
for m in re.finditer(r"\$\{([A-Za-z_][A-Za-z0-9_]*)\}", obj):
|
|
58
|
+
refs.add(m.group(1))
|
|
59
|
+
elif isinstance(obj, dict):
|
|
60
|
+
for v in obj.values():
|
|
61
|
+
refs.update(_collect_mcp_env_refs(v))
|
|
62
|
+
elif isinstance(obj, list):
|
|
63
|
+
for v in obj:
|
|
64
|
+
refs.update(_collect_mcp_env_refs(v))
|
|
65
|
+
return refs
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def run_validate(args: argparse.Namespace) -> int:
|
|
69
|
+
cwd = Path(args.cwd).expanduser().resolve() if args.cwd else None
|
|
70
|
+
config_path = resolve_config(args.config, cwd=cwd)
|
|
71
|
+
load_agent_dotenv(cwd=cwd, config_path=config_path)
|
|
72
|
+
report = CommandReport(command="validate", ok=True, config_path=None)
|
|
73
|
+
|
|
74
|
+
if config_path is None:
|
|
75
|
+
check(
|
|
76
|
+
report,
|
|
77
|
+
id="config.file.exists",
|
|
78
|
+
category="config",
|
|
79
|
+
severity="error",
|
|
80
|
+
passed=False,
|
|
81
|
+
message="monkeybot.yaml not found",
|
|
82
|
+
remediation="Run `monkeybot new` or set MONKEYBOT_CONFIG",
|
|
83
|
+
)
|
|
84
|
+
return report.emit(as_json=args.json)
|
|
85
|
+
|
|
86
|
+
report.config_path = str(config_path.resolve())
|
|
87
|
+
base = config_path.parent.parent if config_path.parent.name == "monkeybot_config" else Path.cwd()
|
|
88
|
+
|
|
89
|
+
try:
|
|
90
|
+
raw = config_path.read_text(encoding="utf-8")
|
|
91
|
+
doc = yaml.safe_load(raw)
|
|
92
|
+
check(report, id="config.yaml.parses", category="config", severity="error", passed=True)
|
|
93
|
+
except yaml.YAMLError as exc:
|
|
94
|
+
check(
|
|
95
|
+
report,
|
|
96
|
+
id="config.yaml.parses",
|
|
97
|
+
category="config",
|
|
98
|
+
severity="error",
|
|
99
|
+
passed=False,
|
|
100
|
+
message=str(exc),
|
|
101
|
+
)
|
|
102
|
+
return report.emit(as_json=args.json)
|
|
103
|
+
|
|
104
|
+
if not isinstance(doc, dict):
|
|
105
|
+
check(
|
|
106
|
+
report,
|
|
107
|
+
id="config.root.is_mapping",
|
|
108
|
+
category="config",
|
|
109
|
+
severity="error",
|
|
110
|
+
passed=False,
|
|
111
|
+
message=f"Expected mapping at root, got {type(doc).__name__}",
|
|
112
|
+
)
|
|
113
|
+
return report.emit(as_json=args.json)
|
|
114
|
+
check(report, id="config.root.is_mapping", category="config", severity="error", passed=True)
|
|
115
|
+
|
|
116
|
+
_, merged = load_monkeybot_yaml_dict(config_path)
|
|
117
|
+
includes = doc.get("includes")
|
|
118
|
+
if includes:
|
|
119
|
+
missing = []
|
|
120
|
+
if isinstance(includes, list):
|
|
121
|
+
for item in includes:
|
|
122
|
+
if isinstance(item, str):
|
|
123
|
+
inc = (config_path.parent / item.strip()).resolve()
|
|
124
|
+
if not inc.is_file():
|
|
125
|
+
missing.append(str(inc))
|
|
126
|
+
check(
|
|
127
|
+
report,
|
|
128
|
+
id="config.includes.resolve",
|
|
129
|
+
category="config",
|
|
130
|
+
severity="warning",
|
|
131
|
+
passed=not missing,
|
|
132
|
+
message=f"Missing includes: {', '.join(missing)}" if missing else "All includes resolved",
|
|
133
|
+
)
|
|
134
|
+
else:
|
|
135
|
+
check(report, id="config.includes.resolve", category="config", severity="warning", passed=True, skip=True)
|
|
136
|
+
|
|
137
|
+
model = merged.get("model") if isinstance(merged.get("model"), dict) else {}
|
|
138
|
+
provider = str(model.get("provider", "")).strip().lower() if isinstance(model, dict) else ""
|
|
139
|
+
model_name = str(model.get("name", "")).strip() if isinstance(model, dict) else ""
|
|
140
|
+
check(
|
|
141
|
+
report,
|
|
142
|
+
id="model.provider.supported",
|
|
143
|
+
category="config",
|
|
144
|
+
severity="error",
|
|
145
|
+
passed=not provider or provider in SUPPORTED_YAML_MODEL_PROVIDERS,
|
|
146
|
+
message=f"model.provider '{provider}' is not supported" if provider and provider not in SUPPORTED_YAML_MODEL_PROVIDERS else "",
|
|
147
|
+
field="model.provider",
|
|
148
|
+
value=provider,
|
|
149
|
+
expected=sorted(SUPPORTED_YAML_MODEL_PROVIDERS),
|
|
150
|
+
remediation="Set model.provider to a supported value.",
|
|
151
|
+
)
|
|
152
|
+
check(
|
|
153
|
+
report,
|
|
154
|
+
id="model.name.present",
|
|
155
|
+
category="config",
|
|
156
|
+
severity="error",
|
|
157
|
+
passed=bool(model_name),
|
|
158
|
+
message="model.name is required",
|
|
159
|
+
field="model.name",
|
|
160
|
+
)
|
|
161
|
+
|
|
162
|
+
memory_uri = ""
|
|
163
|
+
paths = merged.get("paths") if isinstance(merged.get("paths"), dict) else {}
|
|
164
|
+
if isinstance(paths, dict):
|
|
165
|
+
memory_uri = str(paths.get("memory_storage_uri", ""))
|
|
166
|
+
memory_backend = "gcs" if memory_uri.startswith("gcs://") else "local"
|
|
167
|
+
check(
|
|
168
|
+
report,
|
|
169
|
+
id="memory.backend.supported",
|
|
170
|
+
category="config",
|
|
171
|
+
severity="error",
|
|
172
|
+
passed=memory_backend in {"local", "gcs", "drive"},
|
|
173
|
+
message=f"Unsupported memory backend from uri: {memory_uri}",
|
|
174
|
+
)
|
|
175
|
+
|
|
176
|
+
flat = _flatten_to_env(merged)
|
|
177
|
+
flat.update({k: v for k, v in os.environ.items() if k.startswith(("GCP_", "GOOGLE_", "ANTHROPIC_VERTEX"))})
|
|
178
|
+
norm_provider = normalize_model_provider(provider or "gemini")
|
|
179
|
+
needs_gcp = memory_backend == "gcs" or norm_provider == "vertex_anthropic"
|
|
180
|
+
gcp_present = any(
|
|
181
|
+
os.environ.get(k, "").strip() or flat.get(k, "").strip()
|
|
182
|
+
for k in ("GCP_PROJECT_ID", "VERTEX_AI_PROJECT_ID", "ANTHROPIC_VERTEX_PROJECT_ID", "GOOGLE_CLOUD_PROJECT")
|
|
183
|
+
)
|
|
184
|
+
if needs_gcp:
|
|
185
|
+
check(
|
|
186
|
+
report,
|
|
187
|
+
id="gcp.project.required",
|
|
188
|
+
category="config",
|
|
189
|
+
severity="error",
|
|
190
|
+
passed=gcp_present,
|
|
191
|
+
message="GCP project id required for gcs memory or vertex-claude provider",
|
|
192
|
+
remediation="Set gcp.project_id in monkeybot.yaml or GCP_PROJECT_ID in .env",
|
|
193
|
+
)
|
|
194
|
+
else:
|
|
195
|
+
check(report, id="gcp.project.required", category="config", severity="error", passed=True, skip=True)
|
|
196
|
+
|
|
197
|
+
try:
|
|
198
|
+
validate_monkeybot_yaml_doc(merged, env=dict(os.environ))
|
|
199
|
+
validate_provider_env({**flat, "MODEL_PROVIDER": provider or flat.get("MODEL_PROVIDER", "gemini")})
|
|
200
|
+
except ConfigError as exc:
|
|
201
|
+
check(
|
|
202
|
+
report,
|
|
203
|
+
id="model.provider.supported",
|
|
204
|
+
category="config",
|
|
205
|
+
severity="error",
|
|
206
|
+
passed=False,
|
|
207
|
+
message=str(exc).split("\n")[0],
|
|
208
|
+
)
|
|
209
|
+
|
|
210
|
+
if isinstance(paths, dict):
|
|
211
|
+
agent_md = str(paths.get("agent_md", ""))
|
|
212
|
+
if agent_md:
|
|
213
|
+
ap = _resolve_path(base, agent_md)
|
|
214
|
+
check(
|
|
215
|
+
report,
|
|
216
|
+
id="paths.agent_md.exists",
|
|
217
|
+
category="paths",
|
|
218
|
+
severity="error",
|
|
219
|
+
passed=ap.is_file(),
|
|
220
|
+
message=f"Missing AGENT.md at {ap}",
|
|
221
|
+
field="paths.agent_md",
|
|
222
|
+
)
|
|
223
|
+
skills = str(paths.get("skills_path", ""))
|
|
224
|
+
if skills:
|
|
225
|
+
sp = _resolve_path(base, skills)
|
|
226
|
+
check(
|
|
227
|
+
report,
|
|
228
|
+
id="paths.skills_path.exists",
|
|
229
|
+
category="paths",
|
|
230
|
+
severity="warning",
|
|
231
|
+
passed=sp.is_dir(),
|
|
232
|
+
message=f"Skills path missing: {sp}",
|
|
233
|
+
)
|
|
234
|
+
mcp_cfg = str(paths.get("mcp_config", ""))
|
|
235
|
+
mcp_path: Path | None = None
|
|
236
|
+
if mcp_cfg:
|
|
237
|
+
mcp_path = _resolve_path(base, mcp_cfg)
|
|
238
|
+
check(
|
|
239
|
+
report,
|
|
240
|
+
id="paths.mcp_config.exists",
|
|
241
|
+
category="paths",
|
|
242
|
+
severity="error",
|
|
243
|
+
passed=mcp_path.is_file(),
|
|
244
|
+
message=f"Missing MCP config: {mcp_path}",
|
|
245
|
+
)
|
|
246
|
+
allow = str(paths.get("command_allowlist_config", ""))
|
|
247
|
+
if allow:
|
|
248
|
+
ap = _resolve_path(base, allow)
|
|
249
|
+
check(
|
|
250
|
+
report,
|
|
251
|
+
id="paths.command_allowlist.exists",
|
|
252
|
+
category="paths",
|
|
253
|
+
severity="warning",
|
|
254
|
+
passed=ap.is_file(),
|
|
255
|
+
message=f"Missing command allowlist: {ap}",
|
|
256
|
+
)
|
|
257
|
+
if ap.is_file():
|
|
258
|
+
try:
|
|
259
|
+
from monkeybot.core.context.tool_output_policy import (
|
|
260
|
+
load_tool_output_policies,
|
|
261
|
+
validate_tool_output_budgets,
|
|
262
|
+
)
|
|
263
|
+
|
|
264
|
+
policies = load_tool_output_policies(ap)
|
|
265
|
+
for warn in validate_tool_output_budgets(policies):
|
|
266
|
+
check(
|
|
267
|
+
report,
|
|
268
|
+
id="tool_output.budgets.sane",
|
|
269
|
+
category="tools",
|
|
270
|
+
severity="warning",
|
|
271
|
+
passed=False,
|
|
272
|
+
message=warn,
|
|
273
|
+
)
|
|
274
|
+
except Exception as exc:
|
|
275
|
+
check(
|
|
276
|
+
report,
|
|
277
|
+
id="tool_output.parse",
|
|
278
|
+
category="tools",
|
|
279
|
+
severity="warning",
|
|
280
|
+
passed=False,
|
|
281
|
+
message=f"tool_output section: {exc}",
|
|
282
|
+
)
|
|
283
|
+
db_url = str(paths.get("db_url", ""))
|
|
284
|
+
if db_url.startswith("sqlite:///"):
|
|
285
|
+
db_file = Path(db_url.removeprefix("sqlite:///"))
|
|
286
|
+
if not db_file.is_absolute():
|
|
287
|
+
db_file = (base / db_file).resolve()
|
|
288
|
+
writable = True
|
|
289
|
+
try:
|
|
290
|
+
db_file.parent.mkdir(parents=True, exist_ok=True)
|
|
291
|
+
if db_file.exists():
|
|
292
|
+
conn = sqlite3.connect(db_file)
|
|
293
|
+
conn.close()
|
|
294
|
+
else:
|
|
295
|
+
conn = sqlite3.connect(db_file)
|
|
296
|
+
conn.close()
|
|
297
|
+
except OSError as exc:
|
|
298
|
+
writable = False
|
|
299
|
+
msg = str(exc)
|
|
300
|
+
else:
|
|
301
|
+
msg = ""
|
|
302
|
+
check(
|
|
303
|
+
report,
|
|
304
|
+
id="paths.db_url.writable",
|
|
305
|
+
category="paths",
|
|
306
|
+
severity="error",
|
|
307
|
+
passed=writable,
|
|
308
|
+
message=msg or f"SQLite path ok: {db_file}",
|
|
309
|
+
)
|
|
310
|
+
|
|
311
|
+
if mcp_path and mcp_path.is_file():
|
|
312
|
+
try:
|
|
313
|
+
mcp_doc = json.loads(mcp_path.read_text(encoding="utf-8"))
|
|
314
|
+
check(report, id="mcp.json.parses", category="mcp", severity="error", passed=True)
|
|
315
|
+
servers = mcp_doc.get("mcpServers") if isinstance(mcp_doc, dict) else None
|
|
316
|
+
shape_ok = isinstance(servers, dict)
|
|
317
|
+
check(
|
|
318
|
+
report,
|
|
319
|
+
id="mcp.servers.shape_valid",
|
|
320
|
+
category="mcp",
|
|
321
|
+
severity="error",
|
|
322
|
+
passed=shape_ok,
|
|
323
|
+
message="mcp.json must have mcpServers object",
|
|
324
|
+
)
|
|
325
|
+
if shape_ok and isinstance(servers, dict):
|
|
326
|
+
refs = _collect_mcp_env_refs(servers)
|
|
327
|
+
missing_refs = [r for r in refs if not os.environ.get(r, "").strip()]
|
|
328
|
+
check(
|
|
329
|
+
report,
|
|
330
|
+
id="mcp.env_refs.resolvable",
|
|
331
|
+
category="mcp",
|
|
332
|
+
severity="warning",
|
|
333
|
+
passed=not missing_refs,
|
|
334
|
+
message=f"Unresolved MCP env refs: {', '.join(missing_refs)}" if missing_refs else "All MCP env refs set",
|
|
335
|
+
)
|
|
336
|
+
if args.check_mcp:
|
|
337
|
+
for name, srv in servers.items():
|
|
338
|
+
if not isinstance(srv, dict):
|
|
339
|
+
continue
|
|
340
|
+
url = srv.get("url")
|
|
341
|
+
if isinstance(url, str) and url.startswith("http"):
|
|
342
|
+
try:
|
|
343
|
+
httpx.get(url, timeout=3.0)
|
|
344
|
+
reachable = True
|
|
345
|
+
rmsg = f"{name} reachable"
|
|
346
|
+
except Exception as exc:
|
|
347
|
+
reachable = False
|
|
348
|
+
rmsg = f"{name}: {exc}"
|
|
349
|
+
check(
|
|
350
|
+
report,
|
|
351
|
+
id="mcp.server.reachable",
|
|
352
|
+
category="mcp",
|
|
353
|
+
severity="warning",
|
|
354
|
+
passed=reachable,
|
|
355
|
+
message=rmsg,
|
|
356
|
+
)
|
|
357
|
+
else:
|
|
358
|
+
check(
|
|
359
|
+
report,
|
|
360
|
+
id="mcp.server.reachable",
|
|
361
|
+
category="mcp",
|
|
362
|
+
severity="warning",
|
|
363
|
+
passed=True,
|
|
364
|
+
skip=True,
|
|
365
|
+
)
|
|
366
|
+
except json.JSONDecodeError as exc:
|
|
367
|
+
check(
|
|
368
|
+
report,
|
|
369
|
+
id="mcp.json.parses",
|
|
370
|
+
category="mcp",
|
|
371
|
+
severity="error",
|
|
372
|
+
passed=False,
|
|
373
|
+
message=str(exc),
|
|
374
|
+
)
|
|
375
|
+
|
|
376
|
+
return report.emit(as_json=args.json)
|
|
377
|
+
|
|
378
|
+
|
|
379
|
+
def register(subparsers: argparse._SubParsersAction[argparse.ArgumentParser]) -> None:
|
|
380
|
+
p = subparsers.add_parser("validate", help="Validate monkeybot.yaml and related files")
|
|
381
|
+
p.add_argument("--json", action="store_true", help="Emit JSON report")
|
|
382
|
+
p.add_argument("--config", help="Path to monkeybot.yaml")
|
|
383
|
+
p.add_argument("--cwd", help="Working directory for path resolution")
|
|
384
|
+
p.add_argument("--check-mcp", action="store_true", help="Probe MCP HTTP servers (network)")
|
|
385
|
+
p.set_defaults(func=run_validate)
|
monkeybot_cli/compat.py
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
"""Version ranges shared by CLI packaging and agent scaffolding."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
# Floor matches published ``monkeybot-cli`` → ``monkeybot[cli]`` bound.
|
|
6
|
+
# Raise together when scaffolding or gateway APIs break compatibility.
|
|
7
|
+
COMPATIBLE_CORE_RANGE = ">=2.1.0,<3"
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
"""Resolve monkeybot.yaml for CLI commands."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
from dotenv import load_dotenv
|
|
8
|
+
from monkeybot.core.config.yaml_loader import (
|
|
9
|
+
load_monkeybot_yaml_dict,
|
|
10
|
+
resolve_monkeybot_config_path,
|
|
11
|
+
)
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def resolve_agent_root(*, cwd: Path | None = None, config_path: Path | None = None) -> Path:
|
|
15
|
+
"""Return the agent project root (directory containing monkeybot_config/ or .env)."""
|
|
16
|
+
if cwd is not None:
|
|
17
|
+
return cwd.expanduser().resolve()
|
|
18
|
+
if config_path is not None:
|
|
19
|
+
cp = config_path.expanduser().resolve()
|
|
20
|
+
if cp.parent.name == "monkeybot_config":
|
|
21
|
+
return cp.parent.parent
|
|
22
|
+
return cp.parent
|
|
23
|
+
return Path.cwd()
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def load_agent_dotenv(*, cwd: Path | None = None, config_path: Path | None = None) -> Path | None:
|
|
27
|
+
"""Load ``.env`` from the agent root (``--cwd`` or parent of ``monkeybot_config``).
|
|
28
|
+
|
|
29
|
+
``uv run`` sets the process cwd to the CLI package, so bare ``load_dotenv()`` misses
|
|
30
|
+
agent secrets. Existing environment variables are not overridden.
|
|
31
|
+
"""
|
|
32
|
+
root = resolve_agent_root(cwd=cwd, config_path=config_path)
|
|
33
|
+
env_file = root / ".env"
|
|
34
|
+
if env_file.is_file():
|
|
35
|
+
load_dotenv(env_file, override=False)
|
|
36
|
+
return env_file
|
|
37
|
+
fallback = Path.cwd() / ".env"
|
|
38
|
+
if fallback.is_file() and fallback != env_file:
|
|
39
|
+
load_dotenv(fallback, override=False)
|
|
40
|
+
return fallback
|
|
41
|
+
return None
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def resolve_config(explicit: str | None, *, cwd: Path | None = None) -> Path | None:
|
|
45
|
+
"""Resolve config path from --config flag or defaults."""
|
|
46
|
+
if explicit:
|
|
47
|
+
p = Path(explicit).expanduser()
|
|
48
|
+
return p.resolve() if p.is_file() else None
|
|
49
|
+
resolved_cwd = cwd.expanduser().resolve() if cwd is not None else None
|
|
50
|
+
return resolve_monkeybot_config_path(cwd=resolved_cwd)
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def load_config_doc(config_path: str | Path | None = None) -> tuple[Path | None, dict]:
|
|
54
|
+
path, doc = load_monkeybot_yaml_dict(config_path)
|
|
55
|
+
return path, doc if isinstance(doc, dict) else {}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
"""Shared exit-command parsing for chat and talk."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
_EXIT_COMMANDS = frozenset({"/bye", "/quit", "/exit"})
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def is_exit_command(line: str) -> bool:
|
|
9
|
+
"""Return True for /bye, /quit, /exit (optional trailing punctuation)."""
|
|
10
|
+
token = line.strip().lower().split(maxsplit=1)[0] if line.strip() else ""
|
|
11
|
+
while token and token[-1] in ".,!;:":
|
|
12
|
+
token = token[:-1]
|
|
13
|
+
return token in _EXIT_COMMANDS
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
"""Catalog of agent-selectable monkeybot extras for ``monkeybot new``."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
|
|
7
|
+
from monkeybot_cli.providers import PROVIDER_SPECS, spec_for_provider
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
@dataclass(frozen=True)
|
|
11
|
+
class ExtraChoice:
|
|
12
|
+
"""One selectable optional-dependency row."""
|
|
13
|
+
|
|
14
|
+
key: str # package extra name or YAML provider id (for provider menu)
|
|
15
|
+
label: str
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
# Primary provider menu — keys are YAML ``model.provider`` values.
|
|
19
|
+
# ``fake`` is intentionally omitted: it remains valid via ``--provider fake`` for CI/smoke.
|
|
20
|
+
PROVIDER_CHOICES: tuple[ExtraChoice, ...] = (
|
|
21
|
+
ExtraChoice("gemini", "Gemini / Vertex AI"),
|
|
22
|
+
ExtraChoice("openai", "OpenAI"),
|
|
23
|
+
ExtraChoice("anthropic", "Anthropic (Claude API)"),
|
|
24
|
+
ExtraChoice("vertex-claude", "Claude on Vertex AI"),
|
|
25
|
+
ExtraChoice("aws_bedrock", "AWS Bedrock"),
|
|
26
|
+
ExtraChoice("huggingface", "Hugging Face"),
|
|
27
|
+
ExtraChoice("ollama", "Ollama (local)"),
|
|
28
|
+
ExtraChoice("nvidia", "NVIDIA NIM"),
|
|
29
|
+
)
|
|
30
|
+
|
|
31
|
+
# Non-provider agent features (root ``[project.optional-dependencies]`` names).
|
|
32
|
+
FEATURE_CHOICES: tuple[ExtraChoice, ...] = (
|
|
33
|
+
ExtraChoice("postgres", "Postgres conversation store (parallel subagents)"),
|
|
34
|
+
ExtraChoice("firestore", "Firestore storage"),
|
|
35
|
+
ExtraChoice("gcs", "Google Cloud Storage"),
|
|
36
|
+
ExtraChoice("sandbox", "OpenSandbox code execution"),
|
|
37
|
+
ExtraChoice("web-search", "DuckDuckGo web search (ddgs)"),
|
|
38
|
+
ExtraChoice("observability", "OpenTelemetry tracing"),
|
|
39
|
+
ExtraChoice("scheduler", "Cron scheduler"),
|
|
40
|
+
ExtraChoice("council", "Council / multi-agent GCS helpers"),
|
|
41
|
+
ExtraChoice("aws", "AWS helpers (boto3)"),
|
|
42
|
+
ExtraChoice("realtime", "Realtime WebSocket gateway support"),
|
|
43
|
+
ExtraChoice("realtime-gemini", "Gemini Live realtime"),
|
|
44
|
+
ExtraChoice("cli-realtime", "CLI talk audio (PortAudio / push-to-talk)"),
|
|
45
|
+
)
|
|
46
|
+
|
|
47
|
+
_FEATURE_KEYS = frozenset(c.key for c in FEATURE_CHOICES)
|
|
48
|
+
_KNOWN_PACKAGE_EXTRAS = frozenset(
|
|
49
|
+
{s.extra for s in PROVIDER_SPECS.values() if s.extra} | _FEATURE_KEYS
|
|
50
|
+
)
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def provider_extra_name(yaml_provider: str | None) -> str | None:
|
|
54
|
+
"""Map a YAML provider id to its package extra (None for fake / unknown)."""
|
|
55
|
+
if not yaml_provider:
|
|
56
|
+
return None
|
|
57
|
+
spec = spec_for_provider(yaml_provider)
|
|
58
|
+
return spec.extra if spec is not None else None
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def normalize_extra_token(raw: str) -> str | None:
|
|
62
|
+
"""Accept ``--with`` tokens: feature keys, package extras, or YAML provider aliases."""
|
|
63
|
+
token = raw.strip()
|
|
64
|
+
if not token:
|
|
65
|
+
return None
|
|
66
|
+
if token in _FEATURE_KEYS:
|
|
67
|
+
return token
|
|
68
|
+
if token in _KNOWN_PACKAGE_EXTRAS:
|
|
69
|
+
return token
|
|
70
|
+
spec = spec_for_provider(token)
|
|
71
|
+
if spec is not None:
|
|
72
|
+
return spec.extra # may be None for fake — caller should skip
|
|
73
|
+
# hyphen/underscore variants for features
|
|
74
|
+
alt = token.replace("_", "-")
|
|
75
|
+
if alt in _FEATURE_KEYS:
|
|
76
|
+
return alt
|
|
77
|
+
alt2 = token.replace("-", "_")
|
|
78
|
+
spec = spec_for_provider(alt2)
|
|
79
|
+
if spec is not None:
|
|
80
|
+
return spec.extra
|
|
81
|
+
return None
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def additional_provider_extra_choices(primary_yaml: str | None) -> tuple[ExtraChoice, ...]:
|
|
85
|
+
"""Other provider package extras (beyond the primary) for multi-select."""
|
|
86
|
+
primary_extra = provider_extra_name(primary_yaml)
|
|
87
|
+
seen: set[str] = set()
|
|
88
|
+
rows: list[ExtraChoice] = []
|
|
89
|
+
for choice in PROVIDER_CHOICES:
|
|
90
|
+
extra = provider_extra_name(choice.key)
|
|
91
|
+
if extra is None or extra == primary_extra or extra in seen:
|
|
92
|
+
continue
|
|
93
|
+
seen.add(extra)
|
|
94
|
+
rows.append(ExtraChoice(extra, choice.label))
|
|
95
|
+
return tuple(rows)
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
"""Gateway health polling helpers shared by chat and talk."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import subprocess
|
|
6
|
+
import time
|
|
7
|
+
|
|
8
|
+
import httpx
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def wait_for_health(
|
|
12
|
+
base: str, proc: subprocess.Popen[str] | None, timeout_s: float = 30.0
|
|
13
|
+
) -> bool:
|
|
14
|
+
"""Poll ``GET {base}/health`` until 200, process dies, or timeout."""
|
|
15
|
+
deadline = time.monotonic() + timeout_s
|
|
16
|
+
while time.monotonic() < deadline:
|
|
17
|
+
if proc is not None and proc.poll() is not None:
|
|
18
|
+
return False
|
|
19
|
+
try:
|
|
20
|
+
resp = httpx.get(f"{base}/health", timeout=2.0)
|
|
21
|
+
if resp.status_code == 200:
|
|
22
|
+
return True
|
|
23
|
+
except httpx.HTTPError:
|
|
24
|
+
pass
|
|
25
|
+
time.sleep(0.3)
|
|
26
|
+
return False
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def health_ok(base: str) -> bool:
|
|
30
|
+
"""Single-shot health check."""
|
|
31
|
+
try:
|
|
32
|
+
return httpx.get(f"{base}/health", timeout=2.0).status_code == 200
|
|
33
|
+
except httpx.HTTPError:
|
|
34
|
+
return False
|
monkeybot_cli/main.py
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
"""monkeybot CLI entry point."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import argparse
|
|
6
|
+
import sys
|
|
7
|
+
|
|
8
|
+
from monkeybot_cli.commands import chat, doctor, loop, new, run_cmd, talk, validate
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def build_parser() -> argparse.ArgumentParser:
|
|
12
|
+
parser = argparse.ArgumentParser(
|
|
13
|
+
prog="monkeybot",
|
|
14
|
+
description="Create, configure, validate, and chat with monkeybot agents.",
|
|
15
|
+
)
|
|
16
|
+
parser.add_argument("--json", action="store_true", help="JSON output (validate/doctor)")
|
|
17
|
+
sub = parser.add_subparsers(dest="command", required=True)
|
|
18
|
+
new.register(sub)
|
|
19
|
+
validate.register(sub)
|
|
20
|
+
doctor.register(sub)
|
|
21
|
+
run_cmd.register(sub)
|
|
22
|
+
chat.register(sub)
|
|
23
|
+
talk.register(sub)
|
|
24
|
+
loop.register(sub)
|
|
25
|
+
return parser
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def main(argv: list[str] | None = None) -> int:
|
|
29
|
+
argv = argv if argv is not None else sys.argv[1:]
|
|
30
|
+
args = build_parser().parse_args(argv)
|
|
31
|
+
try:
|
|
32
|
+
return int(args.func(args))
|
|
33
|
+
except KeyboardInterrupt:
|
|
34
|
+
return 130
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
if __name__ == "__main__":
|
|
38
|
+
raise SystemExit(main())
|