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,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)
@@ -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())