monkeybot-cli 0.9.0__tar.gz → 0.11.0__tar.gz

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 (94) hide show
  1. {monkeybot_cli-0.9.0 → monkeybot_cli-0.11.0}/.gitignore +5 -1
  2. {monkeybot_cli-0.9.0 → monkeybot_cli-0.11.0}/PKG-INFO +2 -2
  3. {monkeybot_cli-0.9.0 → monkeybot_cli-0.11.0}/pyproject.toml +2 -2
  4. {monkeybot_cli-0.9.0 → monkeybot_cli-0.11.0}/skills/monkeybot/references/config-sections.md +3 -2
  5. {monkeybot_cli-0.9.0 → monkeybot_cli-0.11.0}/src/monkeybot_cli/chat_session.py +3 -9
  6. {monkeybot_cli-0.9.0 → monkeybot_cli-0.11.0}/src/monkeybot_cli/chat_tool_display.py +1 -3
  7. {monkeybot_cli-0.9.0 → monkeybot_cli-0.11.0}/src/monkeybot_cli/chat_tui.py +20 -28
  8. {monkeybot_cli-0.9.0 → monkeybot_cli-0.11.0}/src/monkeybot_cli/commands/chat.py +2 -3
  9. {monkeybot_cli-0.9.0 → monkeybot_cli-0.11.0}/src/monkeybot_cli/commands/doctor.py +64 -1
  10. {monkeybot_cli-0.9.0 → monkeybot_cli-0.11.0}/src/monkeybot_cli/commands/loop.py +6 -1
  11. monkeybot_cli-0.11.0/src/monkeybot_cli/commands/mcp_login.py +81 -0
  12. {monkeybot_cli-0.9.0 → monkeybot_cli-0.11.0}/src/monkeybot_cli/commands/new.py +45 -7
  13. {monkeybot_cli-0.9.0 → monkeybot_cli-0.11.0}/src/monkeybot_cli/commands/validate.py +41 -8
  14. {monkeybot_cli-0.9.0 → monkeybot_cli-0.11.0}/src/monkeybot_cli/compat.py +2 -6
  15. {monkeybot_cli-0.9.0 → monkeybot_cli-0.11.0}/src/monkeybot_cli/extras_catalog.py +1 -0
  16. {monkeybot_cli-0.9.0 → monkeybot_cli-0.11.0}/src/monkeybot_cli/gateway_health.py +1 -3
  17. {monkeybot_cli-0.9.0 → monkeybot_cli-0.11.0}/src/monkeybot_cli/main.py +13 -1
  18. {monkeybot_cli-0.9.0 → monkeybot_cli-0.11.0}/src/monkeybot_cli/opensandbox_lifecycle.py +4 -3
  19. {monkeybot_cli-0.9.0 → monkeybot_cli-0.11.0}/src/monkeybot_cli/output.py +3 -1
  20. {monkeybot_cli-0.9.0 → monkeybot_cli-0.11.0}/src/monkeybot_cli/providers.py +2 -0
  21. {monkeybot_cli-0.9.0 → monkeybot_cli-0.11.0}/src/monkeybot_cli/realtime/push_to_talk.py +1 -2
  22. {monkeybot_cli-0.9.0 → monkeybot_cli-0.11.0}/src/monkeybot_cli/realtime/session_controller.py +1 -3
  23. {monkeybot_cli-0.9.0 → monkeybot_cli-0.11.0}/src/monkeybot_cli/scaffold.py +86 -16
  24. {monkeybot_cli-0.9.0 → monkeybot_cli-0.11.0}/src/monkeybot_cli/scaffold_defaults/command_allowlist.yaml +2 -0
  25. {monkeybot_cli-0.9.0 → monkeybot_cli-0.11.0}/src/monkeybot_cli/scaffold_defaults/env.example +3 -0
  26. {monkeybot_cli-0.9.0 → monkeybot_cli-0.11.0}/src/monkeybot_cli/scaffold_defaults/monkeybot.example.yaml +26 -2
  27. monkeybot_cli-0.11.0/src/monkeybot_cli/scaffold_defaults/templates/coding/AGENT.md +35 -0
  28. monkeybot_cli-0.11.0/src/monkeybot_cli/scaffold_defaults/templates/coding/command_allowlist.yaml +92 -0
  29. monkeybot_cli-0.11.0/src/monkeybot_cli/scaffold_defaults/templates/coding/monkeybot.overrides.yaml +31 -0
  30. monkeybot_cli-0.11.0/src/monkeybot_cli/scaffold_defaults/templates/coding/permissions.yaml +42 -0
  31. {monkeybot_cli-0.9.0 → monkeybot_cli-0.11.0}/tests/test_chat_continue.py +1 -3
  32. {monkeybot_cli-0.9.0 → monkeybot_cli-0.11.0}/tests/test_chat_errors.py +12 -4
  33. {monkeybot_cli-0.9.0 → monkeybot_cli-0.11.0}/tests/test_chat_local_shell.py +3 -1
  34. {monkeybot_cli-0.9.0 → monkeybot_cli-0.11.0}/tests/test_chat_renderer_parity.py +1 -3
  35. {monkeybot_cli-0.9.0 → monkeybot_cli-0.11.0}/tests/test_chat_session.py +1 -3
  36. {monkeybot_cli-0.9.0 → monkeybot_cli-0.11.0}/tests/test_chat_tui.py +15 -18
  37. {monkeybot_cli-0.9.0 → monkeybot_cli-0.11.0}/tests/test_cli.py +53 -3
  38. {monkeybot_cli-0.9.0 → monkeybot_cli-0.11.0}/tests/test_config_resolve.py +3 -1
  39. {monkeybot_cli-0.9.0 → monkeybot_cli-0.11.0}/tests/test_doctor.py +52 -0
  40. {monkeybot_cli-0.9.0 → monkeybot_cli-0.11.0}/tests/test_realtime_session_controller.py +1 -3
  41. {monkeybot_cli-0.9.0 → monkeybot_cli-0.11.0}/tests/test_run_cmd.py +6 -2
  42. {monkeybot_cli-0.9.0 → monkeybot_cli-0.11.0}/tests/test_runtime_python.py +3 -1
  43. {monkeybot_cli-0.9.0 → monkeybot_cli-0.11.0}/tests/test_scaffold.py +193 -0
  44. {monkeybot_cli-0.9.0 → monkeybot_cli-0.11.0}/tests/test_terminal_markdown.py +1 -5
  45. {monkeybot_cli-0.9.0 → monkeybot_cli-0.11.0}/uv.lock +7 -3
  46. {monkeybot_cli-0.9.0 → monkeybot_cli-0.11.0}/skills/monkeybot/SKILL.md +0 -0
  47. {monkeybot_cli-0.9.0 → monkeybot_cli-0.11.0}/src/monkeybot_cli/__init__.py +0 -0
  48. {monkeybot_cli-0.9.0 → monkeybot_cli-0.11.0}/src/monkeybot_cli/chat_file_index.py +0 -0
  49. {monkeybot_cli-0.9.0 → monkeybot_cli-0.11.0}/src/monkeybot_cli/chat_local_shell.py +0 -0
  50. {monkeybot_cli-0.9.0 → monkeybot_cli-0.11.0}/src/monkeybot_cli/chat_renderer.py +0 -0
  51. {monkeybot_cli-0.9.0 → monkeybot_cli-0.11.0}/src/monkeybot_cli/chat_status_bar.py +0 -0
  52. {monkeybot_cli-0.9.0 → monkeybot_cli-0.11.0}/src/monkeybot_cli/chat_theme.py +0 -0
  53. {monkeybot_cli-0.9.0 → monkeybot_cli-0.11.0}/src/monkeybot_cli/chat_tui_widgets.py +0 -0
  54. {monkeybot_cli-0.9.0 → monkeybot_cli-0.11.0}/src/monkeybot_cli/commands/__init__.py +0 -0
  55. {monkeybot_cli-0.9.0 → monkeybot_cli-0.11.0}/src/monkeybot_cli/commands/refresh.py +0 -0
  56. {monkeybot_cli-0.9.0 → monkeybot_cli-0.11.0}/src/monkeybot_cli/commands/run_cmd.py +0 -0
  57. {monkeybot_cli-0.9.0 → monkeybot_cli-0.11.0}/src/monkeybot_cli/commands/talk.py +0 -0
  58. {monkeybot_cli-0.9.0 → monkeybot_cli-0.11.0}/src/monkeybot_cli/commands/trace.py +0 -0
  59. {monkeybot_cli-0.9.0 → monkeybot_cli-0.11.0}/src/monkeybot_cli/config_resolve.py +0 -0
  60. {monkeybot_cli-0.9.0 → monkeybot_cli-0.11.0}/src/monkeybot_cli/exit_commands.py +0 -0
  61. {monkeybot_cli-0.9.0 → monkeybot_cli-0.11.0}/src/monkeybot_cli/process_tree.py +0 -0
  62. {monkeybot_cli-0.9.0 → monkeybot_cli-0.11.0}/src/monkeybot_cli/realtime/__init__.py +0 -0
  63. {monkeybot_cli-0.9.0 → monkeybot_cli-0.11.0}/src/monkeybot_cli/realtime/audio_io.py +0 -0
  64. {monkeybot_cli-0.9.0 → monkeybot_cli-0.11.0}/src/monkeybot_cli/realtime/client.py +0 -0
  65. {monkeybot_cli-0.9.0 → monkeybot_cli-0.11.0}/src/monkeybot_cli/realtime/gateway_manager.py +0 -0
  66. {monkeybot_cli-0.9.0 → monkeybot_cli-0.11.0}/src/monkeybot_cli/realtime/session.py +0 -0
  67. {monkeybot_cli-0.9.0 → monkeybot_cli-0.11.0}/src/monkeybot_cli/realtime/talk_ui.py +0 -0
  68. {monkeybot_cli-0.9.0 → monkeybot_cli-0.11.0}/src/monkeybot_cli/realtime/wire_encode.py +0 -0
  69. {monkeybot_cli-0.9.0 → monkeybot_cli-0.11.0}/src/monkeybot_cli/runtime_python.py +0 -0
  70. {monkeybot_cli-0.9.0 → monkeybot_cli-0.11.0}/src/monkeybot_cli/scaffold_defaults/AGENT.md +0 -0
  71. {monkeybot_cli-0.9.0 → monkeybot_cli-0.11.0}/src/monkeybot_cli/scaffold_defaults/Dockerfile +0 -0
  72. {monkeybot_cli-0.9.0 → monkeybot_cli-0.11.0}/src/monkeybot_cli/scaffold_defaults/__init__.py +0 -0
  73. {monkeybot_cli-0.9.0 → monkeybot_cli-0.11.0}/src/monkeybot_cli/scaffold_defaults/browser/SKILL.md +0 -0
  74. {monkeybot_cli-0.9.0 → monkeybot_cli-0.11.0}/src/monkeybot_cli/scaffold_defaults/dockerignore +0 -0
  75. {monkeybot_cli-0.9.0 → monkeybot_cli-0.11.0}/src/monkeybot_cli/scaffold_defaults/goal/SKILL.md +0 -0
  76. {monkeybot_cli-0.9.0 → monkeybot_cli-0.11.0}/src/monkeybot_cli/scaffold_defaults/image-generator/SKILL.md +0 -0
  77. {monkeybot_cli-0.9.0 → monkeybot_cli-0.11.0}/src/monkeybot_cli/scaffold_defaults/image-generator/generate_image.py +0 -0
  78. {monkeybot_cli-0.9.0 → monkeybot_cli-0.11.0}/src/monkeybot_cli/scaffold_defaults/loop/SKILL.md +0 -0
  79. {monkeybot_cli-0.9.0 → monkeybot_cli-0.11.0}/src/monkeybot_cli/scaffold_defaults/mcp.json +0 -0
  80. {monkeybot_cli-0.9.0 → monkeybot_cli-0.11.0}/src/monkeybot_cli/scaffold_defaults/opensandbox.docker.toml +0 -0
  81. {monkeybot_cli-0.9.0 → monkeybot_cli-0.11.0}/src/monkeybot_cli/scaffold_defaults/otel-collector.example.yaml +0 -0
  82. {monkeybot_cli-0.9.0 → monkeybot_cli-0.11.0}/src/monkeybot_cli/scaffold_defaults/permissions.yaml +0 -0
  83. {monkeybot_cli-0.9.0 → monkeybot_cli-0.11.0}/src/monkeybot_cli/session_controller.py +0 -0
  84. {monkeybot_cli-0.9.0 → monkeybot_cli-0.11.0}/src/monkeybot_cli/terminal_markdown.py +0 -0
  85. {monkeybot_cli-0.9.0 → monkeybot_cli-0.11.0}/tests/test_chat_e2e.py +0 -0
  86. {monkeybot_cli-0.9.0 → monkeybot_cli-0.11.0}/tests/test_chat_file_index.py +0 -0
  87. {monkeybot_cli-0.9.0 → monkeybot_cli-0.11.0}/tests/test_chat_grounding.py +0 -0
  88. {monkeybot_cli-0.9.0 → monkeybot_cli-0.11.0}/tests/test_chat_status_bar.py +0 -0
  89. {monkeybot_cli-0.9.0 → monkeybot_cli-0.11.0}/tests/test_chat_theme.py +0 -0
  90. {monkeybot_cli-0.9.0 → monkeybot_cli-0.11.0}/tests/test_chat_tool_display.py +0 -0
  91. {monkeybot_cli-0.9.0 → monkeybot_cli-0.11.0}/tests/test_extras_catalog.py +0 -0
  92. {monkeybot_cli-0.9.0 → monkeybot_cli-0.11.0}/tests/test_gateway_health.py +0 -0
  93. {monkeybot_cli-0.9.0 → monkeybot_cli-0.11.0}/tests/test_opensandbox_lifecycle.py +0 -0
  94. {monkeybot_cli-0.9.0 → monkeybot_cli-0.11.0}/tests/test_trace_cmd.py +0 -0
@@ -68,4 +68,8 @@ code/
68
68
  .evals-venv/
69
69
 
70
70
  # monkeythink
71
- .monkeythink/
71
+ .monkeythink/
72
+ # Eval fixtures ship their own build/ decoy
73
+ !/evals/fixtures/coding/calc_repo/build/
74
+ !/evals/fixtures/coding/calc_repo_green/build/
75
+ !/evals/fixtures/coding/shop_repo/build/
@@ -1,11 +1,11 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: monkeybot-cli
3
- Version: 0.9.0
3
+ Version: 0.11.0
4
4
  Summary: CLI to create, configure, validate, and chat with monkeybot agents.
5
5
  Requires-Python: >=3.11
6
6
  Requires-Dist: httpx>=0.27.0
7
7
  Requires-Dist: monkeybot-browser-mcp<1,>=0.5.0
8
- Requires-Dist: monkeybot[cli]<4,>=3.8.0
8
+ Requires-Dist: monkeybot[cli]<4,>=3.10.0
9
9
  Requires-Dist: python-dotenv>=1.0.0
10
10
  Requires-Dist: pyyaml>=6.0.2
11
11
  Requires-Dist: textual>=8.2.8
@@ -4,13 +4,13 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "monkeybot-cli"
7
- version = "0.9.0"
7
+ version = "0.11.0"
8
8
  description = "CLI to create, configure, validate, and chat with monkeybot agents."
9
9
  requires-python = ">=3.11"
10
10
  dependencies = [
11
11
  # `cli` pulls realtime + typer so `monkeybot talk` works out of the box (text mode).
12
12
  # Audio mic/PTT needs the agent (or this env) to also install monkeybot[cli-realtime].
13
- "monkeybot[cli]>=3.8.0,<4",
13
+ "monkeybot[cli]>=3.10.0,<4",
14
14
  "monkeybot-browser-mcp>=0.5.0,<1",
15
15
  "httpx>=0.27.0",
16
16
  "pyyaml>=6.0.2",
@@ -2,7 +2,7 @@
2
2
 
3
3
  Deep reference for every `monkeybot.yaml` section. Load this only when a user needs to customize beyond Tier 1. The canonical, fully-commented template lives at `cli/src/monkeybot_cli/scaffold_defaults/monkeybot.example.yaml` in the monkeybot repo (copied to `monkeybot_config/monkeybot.example.yaml` when you scaffold); this file adds the **"when would I change this?"** context the comments don't.
4
4
 
5
- **Precedence:** env vars and `.env` win over YAML for `ENV_MAP` keys. `model.*` (provider, name, temperature, max_tokens, thinking_budget, context_window, summarization_model, max_turns, cache_retention) is YAML-only — leftover `MODEL_*` env vars are ignored. The YAML→env mapping is `ENV_MAP` in `src/monkeybot/core/config/runtime_env.py`. If a YAML edit has no effect, check for a shadowing env var (except `model.*`).
5
+ **Precedence:** env vars and `.env` win over YAML for `ENV_MAP` keys. `model.*` (provider, name, temperature, max_tokens, thinking_budget, reasoning_effort, context_window, summarization_model, max_turns, cache_retention) is YAML-only — leftover `MODEL_*` env vars are ignored. The YAML→env mapping is `ENV_MAP` in `src/monkeybot/core/config/runtime_env.py`. If a YAML edit has no effect, check for a shadowing env var (except `model.*`).
6
6
 
7
7
  ---
8
8
 
@@ -42,7 +42,8 @@ YAML-only for provider, name, sampling, context window, summarization model, max
42
42
  | `name` | `gemini-3-flash` | Pick a specific model id |
43
43
  | `temperature` | `0.7` | Lower for deterministic output, higher for creative |
44
44
  | `max_tokens` | `60000` | Cap per-response length |
45
- | `thinking_budget` | `-1` | Gemini: `-1` model default, `0` off, `N` token budget. Ollama reasoning models: `-1` server default, `0` off (`reasoning_effort: none`) |
45
+ | `thinking_budget` | `-1` | Gemini: `-1` model default, `0` off, `N` token budget. Ollama reasoning models: `-1` server default, `0` off (`reasoning_effort: none`). Ignored when `reasoning_effort` is set on providers that take an effort level |
46
+ | `reasoning_effort` | (unset) | Effort level passed through to the provider (`low`, `medium`, `high`, `xhigh`, `max`, or whatever that provider accepts). Omit for the provider default. YAML-only. Gemini only sends SDK levels (`minimal`, `low`, `medium`, `high`) and otherwise uses `thinking_budget`. A level the API rejects is omitted on later turns. Bedrock sends it for Claude ids only; Converse models ignore it. Curator and summarizer calls skip the configured effort; when one is set, OpenAI and Ollama send `none` instead |
46
47
  | `context_window` | `1000000` | Summarization trigger (tokens); also drives soft-spill / `read_file` char budgets. **Not** Ollama `num_ctx` |
47
48
  | `keep_alive` | `24h` (ollama-local only) | How long local Ollama keeps the model (and KV prefix cache) loaded. YAML only. Set `"0"` to omit. See `docs/ollama-local.md` |
48
49
  | `num_ctx` | (unset) | Optional pinned Ollama `num_ctx`. YAML only. Omit = server default. Do not copy `context_window` |
@@ -101,9 +101,7 @@ def extract_elicitation_fields(payload: dict[str, Any]) -> tuple[str | None, dic
101
101
  else:
102
102
  message = message.strip()
103
103
  schema_raw = (
104
- payload.get("requestedSchema")
105
- or payload.get("requested_schema")
106
- or payload.get("schema")
104
+ payload.get("requestedSchema") or payload.get("requested_schema") or payload.get("schema")
107
105
  )
108
106
  schema = dict(schema_raw) if isinstance(schema_raw, dict) else None
109
107
  return message, schema
@@ -662,9 +660,7 @@ class ChatSessionController:
662
660
 
663
661
  self._maybe_thinking_trace(evt, request_id)
664
662
 
665
- def _on_context_usage_hint(
666
- self, *, estimated_tokens: int, context_window_tokens: int
667
- ) -> None:
663
+ def _on_context_usage_hint(self, *, estimated_tokens: int, context_window_tokens: int) -> None:
668
664
  self.usage.update_context_hint(
669
665
  estimated_prompt_tokens=estimated_tokens,
670
666
  context_window_tokens=context_window_tokens,
@@ -750,9 +746,7 @@ class ChatSessionController:
750
746
  if answer.cancelled:
751
747
  approved = False
752
748
  reason = (
753
- "cancelled by user"
754
- if answer.cancelled
755
- else (None if approved else "denied by user")
749
+ "cancelled by user" if answer.cancelled else (None if approved else "denied by user")
756
750
  )
757
751
  ok = await self._post_hitl(
758
752
  f"{self.base}/sessions/{self.session_id}/tool-confirmations/{evt.tool_call_id}",
@@ -37,9 +37,7 @@ _SHELL_TAIL_LINES = 40
37
37
 
38
38
  _SHELL_TOOLS = frozenset({"run_command", "execute", "shell", "bash"})
39
39
  _READ_TOOLS = frozenset({"read_file", "read"})
40
- _EDIT_TOOLS = frozenset(
41
- {"write_file", "write", "edit_file", "apply_patch", "str_replace"}
42
- )
40
+ _EDIT_TOOLS = frozenset({"write_file", "write", "edit_file", "apply_patch", "str_replace"})
43
41
  _PATH_TOOLS = _READ_TOOLS | _EDIT_TOOLS
44
42
  _SEARCH_TOOLS = frozenset({"grep", "web_search"})
45
43
  _HINT_KEYS = frozenset(
@@ -93,6 +93,7 @@ def cycle_approval_mode(mode: str) -> str:
93
93
  idx = 0
94
94
  return _APPROVAL_MODES[(idx + 1) % len(_APPROVAL_MODES)]
95
95
 
96
+
96
97
  _SLASH_SPECS: tuple[tuple[str, str], ...] = (
97
98
  ("/help", "Show commands and key hints"),
98
99
  ("/new", "Start a fresh session"),
@@ -327,11 +328,7 @@ class ChatApp(App[int]):
327
328
  self.theme = resolve_theme_name(theme_choice)
328
329
  self.base = base
329
330
  self.agent_root = agent_root
330
- self.config_path = (
331
- config_path.expanduser().resolve()
332
- if config_path is not None
333
- else None
334
- )
331
+ self.config_path = config_path.expanduser().resolve() if config_path is not None else None
335
332
  self.provider = provider
336
333
  self.model = model
337
334
  self.spawned_gateway = spawned_gateway
@@ -443,9 +440,7 @@ class ChatApp(App[int]):
443
440
  else:
444
441
  parts.append("y approve · n deny · Ctrl-C cancel")
445
442
  elif self.show_hints:
446
- parts.append(
447
- "Enter send · Ctrl+J newline · ↑ history · / commands · Ctrl+U usage"
448
- )
443
+ parts.append("Enter send · Ctrl+J newline · ↑ history · / commands · Ctrl+U usage")
449
444
  else:
450
445
  parts.append("F1 hints")
451
446
  return " · ".join(parts)
@@ -495,8 +490,7 @@ class ChatApp(App[int]):
495
490
  self._session_cost_text = f"${usage.cost_usd:.4f}"
496
491
  if self.show_usage:
497
492
  self._usage_text = (
498
- f"in={usage.input_tokens} out={usage.output_tokens} "
499
- f"${usage.cost_usd:.4f}"
493
+ f"in={usage.input_tokens} out={usage.output_tokens} ${usage.cost_usd:.4f}"
500
494
  )
501
495
 
502
496
  def _hide_empty_hint(self) -> None:
@@ -556,6 +550,7 @@ class ChatApp(App[int]):
556
550
  if transcript.is_anchored and not transcript._anchor_released:
557
551
  transcript.release_anchor()
558
552
  self._refresh_jump_button()
553
+
559
554
  def _refresh_jump_button(self) -> None:
560
555
  with contextlib.suppress(NoMatches):
561
556
  btn = self.query_one("#jump-bottom", Button)
@@ -627,7 +622,12 @@ class ChatApp(App[int]):
627
622
  if isinstance(child, EarlierTurns):
628
623
  earlier = child
629
624
  continue
630
- if child is self._assistant or child is self._thinking or child is self._thinking_trace or child is self._hitl_card:
625
+ if (
626
+ child is self._assistant
627
+ or child is self._thinking
628
+ or child is self._thinking_trace
629
+ or child is self._hitl_card
630
+ ):
631
631
  continue
632
632
  if isinstance(child, ToolCallBlock) and (
633
633
  child in self._open_tools.values() or child in self._anon_tools
@@ -683,6 +683,7 @@ class ChatApp(App[int]):
683
683
  def _seal_thinking_trace(self) -> None:
684
684
  """Finish the current thinking block and start fresh on the next delta."""
685
685
  self._finish_thinking_trace(clear=True)
686
+
686
687
  def _mount_user(self, body: str) -> None:
687
688
  self._mount(UserTurn(body, show_timestamp=self.show_timestamps))
688
689
 
@@ -768,9 +769,7 @@ class ChatApp(App[int]):
768
769
  palette.display = False
769
770
  palette.clear_options()
770
771
  return
771
- palette.set_options(
772
- [Option(f"{cmd} {desc}", id=cmd) for cmd, desc in matches]
773
- )
772
+ palette.set_options([Option(f"{cmd} {desc}", id=cmd) for cmd, desc in matches])
774
773
  palette.highlighted = 0
775
774
  palette.display = True
776
775
 
@@ -945,9 +944,7 @@ class ChatApp(App[int]):
945
944
  if state == "reconnecting":
946
945
  attempt = p.get("attempt")
947
946
  self._conn_text = (
948
- f"[yellow]reconnecting…[/] ({attempt})"
949
- if attempt
950
- else "[yellow]reconnecting…[/]"
947
+ f"[yellow]reconnecting…[/] ({attempt})" if attempt else "[yellow]reconnecting…[/]"
951
948
  )
952
949
  elif state == "connected":
953
950
  self._conn_text = ""
@@ -1508,7 +1505,9 @@ class ChatApp(App[int]):
1508
1505
  self.model = name
1509
1506
  self._refresh_topbar()
1510
1507
  await self._reset_transcript_ui()
1511
- self._mount_system(f"Model set to {provider}/{name} — new session started (context cleared)")
1508
+ self._mount_system(
1509
+ f"Model set to {provider}/{name} — new session started (context cleared)"
1510
+ )
1512
1511
  self._refresh_status()
1513
1512
  self.query_one("#prompt", Composer).focus()
1514
1513
 
@@ -1566,6 +1565,7 @@ class ChatApp(App[int]):
1566
1565
  except Exception as exc: # noqa: BLE001 - surface any YAML/parse error to the user
1567
1566
  self._mount_system(f"Could not read config: {exc}", error=True)
1568
1567
  return
1568
+
1569
1569
  def _section(key: str) -> dict[str, object]:
1570
1570
  value = doc.get(key)
1571
1571
  return value if isinstance(value, dict) else {}
@@ -1755,7 +1755,6 @@ class ChatApp(App[int]):
1755
1755
  def action_toggle_usage(self) -> None:
1756
1756
  self._cmd_usage()
1757
1757
 
1758
-
1759
1758
  def action_toggle_ptt(self) -> None:
1760
1759
  """Space toggles in-TUI PTT when composer is empty (realtime / SSH)."""
1761
1760
  with contextlib.suppress(NoMatches):
@@ -1786,10 +1785,7 @@ class ChatApp(App[int]):
1786
1785
  self._controller.abort_turn()
1787
1786
  return
1788
1787
  now = time.monotonic()
1789
- if (
1790
- self._last_esc is not None
1791
- and now - self._last_esc <= _ESC_RECALL_WINDOW
1792
- ):
1788
+ if self._last_esc is not None and now - self._last_esc <= _ESC_RECALL_WINDOW:
1793
1789
  self._last_esc = None
1794
1790
  composer.recall_last()
1795
1791
  return
@@ -1815,11 +1811,7 @@ class ChatApp(App[int]):
1815
1811
  self._goodbye_and_exit()
1816
1812
 
1817
1813
  def _goodbye_and_exit(self) -> None:
1818
- msg = (
1819
- "Goodbye — shutting down gateway…"
1820
- if self.spawned_gateway
1821
- else "Goodbye."
1822
- )
1814
+ msg = "Goodbye — shutting down gateway…" if self.spawned_gateway else "Goodbye."
1823
1815
  self._mount_system(msg)
1824
1816
  if self._session_id:
1825
1817
  self._mount_system("To continue this conversation, run: monkeybot chat --continue")
@@ -64,6 +64,7 @@ _UNDERLINE = "\x1b[4m"
64
64
  _SPINNER_FRAMES = "⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏"
65
65
  _GROUNDING_SOURCES_MAX = 5
66
66
 
67
+
67
68
  def _hyperlink(url: str, label: str) -> str:
68
69
  """OSC 8 terminal hyperlink; falls back to a plain URL on non-TTY streams."""
69
70
  if not url:
@@ -278,9 +279,7 @@ def _resolve_base_url(args: argparse.Namespace, config_path: Path | None) -> str
278
279
  return f"http://127.0.0.1:{port}"
279
280
 
280
281
 
281
- def _model_banner_fields(
282
- args: argparse.Namespace, config_path: Path | None
283
- ) -> tuple[str, str]:
282
+ def _model_banner_fields(args: argparse.Namespace, config_path: Path | None) -> tuple[str, str]:
284
283
  provider = (args.model_provider or "").strip()
285
284
  model = (args.model_name or "").strip()
286
285
  if config_path is not None and (not provider or not model):
@@ -10,9 +10,12 @@ import tomllib
10
10
  from pathlib import Path
11
11
 
12
12
  import httpx
13
+ from monkeybot.core.config.settings import normalize_model_provider
13
14
  from monkeybot.core.layout import AgentLayout, bootstrap_agent_layout
14
15
  from monkeybot.core.memory.config import memory_enabled_from_config
15
16
  from monkeybot.core.tools.sandbox_executor import SandboxConfig
17
+ from monkeybot.providers._bedrock_converse import uses_anthropic_bedrock
18
+ from monkeybot.providers.sampling import normalize_reasoning_effort
16
19
 
17
20
  from monkeybot_cli.compat import COMPATIBLE_CORE_RANGE
18
21
  from monkeybot_cli.config_resolve import (
@@ -228,6 +231,58 @@ def _strict_positive_int(raw: object) -> tuple[int | None, bool]:
228
231
  return raw, False
229
232
 
230
233
 
234
+ def _add_reasoning_effort_check(
235
+ report: CommandReport,
236
+ *,
237
+ reasoning_effort: object,
238
+ provider: str = "",
239
+ model_name: str = "",
240
+ ) -> None:
241
+ """Note a YAML effort value.
242
+
243
+ Severity stays ``warning``: the doctor schema has no ``info`` level, and a
244
+ passing check does not increment the warning summary. A non-Claude Bedrock
245
+ model fails this check because the Converse path does not send the field.
246
+ """
247
+ text = normalize_reasoning_effort(reasoning_effort) or ""
248
+ converse = (
249
+ normalize_model_provider(provider) in {"aws_bedrock", "bedrock"}
250
+ and bool(model_name)
251
+ and not uses_anthropic_bedrock(model_name)
252
+ )
253
+ if text == "none":
254
+ message = "reasoning_effort turns thinking off where the provider supports it."
255
+ passed = True
256
+ elif text and converse:
257
+ message = (
258
+ f"reasoning_effort {text!r} is not sent for this Bedrock model. "
259
+ "Only Claude ids receive it; Converse models ignore it."
260
+ )
261
+ passed = False
262
+ elif text:
263
+ message = (
264
+ f"reasoning_effort {text!r} is sent to the provider. "
265
+ "It wins over thinking_budget where the provider accepts an effort level. "
266
+ "Gemini only sends levels its SDK knows; other values use thinking_budget. "
267
+ "A level the API rejects is omitted on later turns."
268
+ )
269
+ passed = True
270
+ else:
271
+ message = "reasoning_effort unset"
272
+ passed = True
273
+ check(
274
+ report,
275
+ id="model.reasoning_effort",
276
+ category="provider",
277
+ severity="warning",
278
+ passed=passed,
279
+ skip=not text,
280
+ message=message,
281
+ field="model.reasoning_effort",
282
+ value=text or None,
283
+ )
284
+
285
+
231
286
  def _add_ollama_local_checks(
232
287
  report: CommandReport,
233
288
  *,
@@ -464,11 +519,19 @@ def run_doctor(args: argparse.Namespace) -> int:
464
519
  skip=True,
465
520
  )
466
521
 
522
+ effort_raw = model.get("reasoning_effort") if isinstance(model, dict) else None
523
+ model_name = str(model.get("name", "")).strip() if isinstance(model, dict) else ""
524
+ _add_reasoning_effort_check(
525
+ report,
526
+ reasoning_effort=effort_raw,
527
+ provider=provider,
528
+ model_name=model_name,
529
+ )
530
+
467
531
  thinking_raw = model.get("thinking_budget") if isinstance(model, dict) else None
468
532
  if thinking_raw is None:
469
533
  thinking_raw = "-1"
470
534
  num_ctx_raw = model.get("num_ctx") if isinstance(model, dict) else None
471
- model_name = str(model.get("name", "")).strip() if isinstance(model, dict) else ""
472
535
  _add_ollama_local_checks(
473
536
  report,
474
537
  provider=provider,
@@ -9,7 +9,12 @@ from pathlib import Path
9
9
 
10
10
  import httpx
11
11
 
12
- from monkeybot_cli.config_resolve import load_agent_dotenv, load_config_doc, resolve_agent_root, resolve_config
12
+ from monkeybot_cli.config_resolve import (
13
+ load_agent_dotenv,
14
+ load_config_doc,
15
+ resolve_agent_root,
16
+ resolve_config,
17
+ )
13
18
 
14
19
  DEFAULT_GATEWAY_PORT = 8000
15
20
 
@@ -0,0 +1,81 @@
1
+ """monkeybot mcp-login — browser sign-in for one remote MCP server."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ import asyncio
7
+ import json
8
+ import logging
9
+ import os
10
+ import sys
11
+ from pathlib import Path
12
+
13
+ from mcp.client.auth.exceptions import OAuthFlowError
14
+ from monkeybot.core.mcp.mcp_client import MCPAuthError
15
+ from monkeybot.core.mcp.oauth_flow import login_mcp_server
16
+
17
+ logger = logging.getLogger(__name__)
18
+
19
+ CLIENT_SECRET_ENV = "MONKEYBOT_MCP_CLIENT_SECRET"
20
+
21
+
22
+ def run_mcp_login(args: argparse.Namespace) -> int:
23
+ """Sign in, print JSON progress on stdout, and return 0 when the attempt finishes."""
24
+ client_secret = args.client_secret or os.environ.get(CLIENT_SECRET_ENV, "")
25
+ try:
26
+ result = asyncio.run(
27
+ login_mcp_server(
28
+ args.url,
29
+ Path(args.storage).expanduser(),
30
+ open_browser=not args.no_browser,
31
+ client_id=args.client_id,
32
+ client_secret=client_secret,
33
+ redirect_port=args.redirect_port,
34
+ )
35
+ )
36
+ except (MCPAuthError, OAuthFlowError) as exc:
37
+ print(str(exc), file=sys.stderr)
38
+ return 1
39
+ except Exception:
40
+ logger.exception("mcp oauth sign-in failed")
41
+ print("Sign-in failed.", file=sys.stderr)
42
+ return 1
43
+ print(json.dumps(result), flush=True)
44
+ return 0
45
+
46
+
47
+ def register(sub: argparse._SubParsersAction[argparse.ArgumentParser]) -> None:
48
+ """Register the ``mcp-login`` subcommand."""
49
+ command = sub.add_parser(
50
+ "mcp-login",
51
+ help="Sign in to a remote MCP server in the browser",
52
+ )
53
+ command.add_argument("--url", required=True, help="Streamable HTTP URL of the MCP server")
54
+ command.add_argument(
55
+ "--storage", required=True, help="Directory for this machine's OAuth tokens"
56
+ )
57
+ command.add_argument(
58
+ "--no-browser",
59
+ action="store_true",
60
+ help="Print the authorization URL instead of opening a browser",
61
+ )
62
+ command.add_argument(
63
+ "--client-id",
64
+ default="",
65
+ help="OAuth client id, for a server that will not register one automatically",
66
+ )
67
+ command.add_argument(
68
+ "--client-secret",
69
+ default="",
70
+ help=(
71
+ f"OAuth client secret, when that client requires one. Prefer {CLIENT_SECRET_ENV}: "
72
+ "command-line arguments are visible to other processes."
73
+ ),
74
+ )
75
+ command.add_argument(
76
+ "--redirect-port",
77
+ type=int,
78
+ default=None,
79
+ help="Loopback port for the sign-in redirect, for a client registered with an exact URI",
80
+ )
81
+ command.set_defaults(func=run_mcp_login)
@@ -13,7 +13,7 @@ from monkeybot_cli.extras_catalog import (
13
13
  additional_provider_extra_choices,
14
14
  normalize_extra_token,
15
15
  )
16
- from monkeybot_cli.scaffold import run_new
16
+ from monkeybot_cli.scaffold import DEFAULT_TEMPLATE, TEMPLATES, run_new
17
17
 
18
18
  _DEFAULT_PROVIDER = "gemini"
19
19
  _DEFAULT_MODEL = "gemini-3-flash"
@@ -26,7 +26,19 @@ def _prompt_line(prompt: str) -> str:
26
26
  return ""
27
27
 
28
28
 
29
- def _prompt_single(choices: tuple[ExtraChoice, ...], *, title: str, default_key: str) -> str:
29
+ TEMPLATE_CHOICES: tuple[ExtraChoice, ...] = (
30
+ ExtraChoice("default", "General-purpose agent"),
31
+ ExtraChoice("coding", "Coding agent for a repository (verification gate, dev toolchain)"),
32
+ )
33
+
34
+
35
+ def _prompt_single(
36
+ choices: tuple[ExtraChoice, ...],
37
+ *,
38
+ title: str,
39
+ default_key: str,
40
+ noun: str = "provider",
41
+ ) -> str:
30
42
  """Numbered single-select; Enter keeps default_key."""
31
43
  print(title)
32
44
  default_idx = 1
@@ -35,7 +47,7 @@ def _prompt_single(choices: tuple[ExtraChoice, ...], *, title: str, default_key:
35
47
  if choice.key == default_key:
36
48
  default_idx = i
37
49
  print(f" {i}) {choice.key} — {choice.label}{marker}")
38
- raw = _prompt_line(f"Choose provider [{default_idx}]: ").strip()
50
+ raw = _prompt_line(f"Choose {noun} [{default_idx}]: ").strip()
39
51
  if not raw:
40
52
  return default_key
41
53
  if raw.isdigit():
@@ -48,7 +60,7 @@ def _prompt_single(choices: tuple[ExtraChoice, ...], *, title: str, default_key:
48
60
  for choice in choices:
49
61
  if raw.lower() == choice.key.lower():
50
62
  return choice.key
51
- print(f" unknown provider {raw!r}; using {default_key}", file=sys.stderr)
63
+ print(f" unknown {noun} {raw!r}; using {default_key}", file=sys.stderr)
52
64
  return default_key
53
65
 
54
66
 
@@ -128,6 +140,13 @@ def run_new_command(args: argparse.Namespace) -> int:
128
140
 
129
141
  provider = args.provider
130
142
  model = args.model
143
+ template = getattr(args, "template", None)
144
+ if template is not None and template not in TEMPLATES:
145
+ print(
146
+ f"error: unknown --template {template!r}; choose from {', '.join(TEMPLATES)}",
147
+ file=sys.stderr,
148
+ )
149
+ return 2
131
150
  extras, unknown = _resolve_extras_from_args(getattr(args, "with_extras", None))
132
151
  if unknown:
133
152
  print(
@@ -137,6 +156,13 @@ def run_new_command(args: argparse.Namespace) -> int:
137
156
  return 2
138
157
 
139
158
  if not args.yes:
159
+ if template is None:
160
+ template = _prompt_single(
161
+ TEMPLATE_CHOICES,
162
+ title="Template:",
163
+ default_key=DEFAULT_TEMPLATE,
164
+ noun="template",
165
+ )
140
166
  if provider is None:
141
167
  provider = _prompt_single(
142
168
  PROVIDER_CHOICES,
@@ -144,9 +170,7 @@ def run_new_command(args: argparse.Namespace) -> int:
144
170
  default_key=_DEFAULT_PROVIDER,
145
171
  )
146
172
  if model is None:
147
- model = (
148
- _prompt_line(f"Model name [{_DEFAULT_MODEL}]: ").strip() or _DEFAULT_MODEL
149
- )
173
+ model = _prompt_line(f"Model name [{_DEFAULT_MODEL}]: ").strip() or _DEFAULT_MODEL
150
174
  # Only prompt for optional deps when --with was not already given.
151
175
  if not extras:
152
176
  more_providers = additional_provider_extra_choices(provider)
@@ -169,6 +193,7 @@ def run_new_command(args: argparse.Namespace) -> int:
169
193
  provider=provider,
170
194
  model=model,
171
195
  extras=extras or None,
196
+ template=template or DEFAULT_TEMPLATE,
172
197
  )
173
198
  print(f"monkeybot scaffold under {dest}:")
174
199
  print("\n".join(report))
@@ -179,6 +204,12 @@ def run_new_command(args: argparse.Namespace) -> int:
179
204
  print(" cp .env.example .env # then add provider keys")
180
205
  print(" monkeybot doctor")
181
206
  print(" monkeybot chat")
207
+ if template == "coding":
208
+ print()
209
+ print("Coding template: clone the repository into ./workspace.")
210
+ print("Path prefixes in command_allowlist.yaml are relative to paths.workspace_root,")
211
+ print("so ../memory is the agent memory only while that stays ./workspace.")
212
+ print("If you set paths.workspace_root to another directory, update those prefixes.")
182
213
  return 0
183
214
 
184
215
 
@@ -202,6 +233,13 @@ def register(subparsers: argparse._SubParsersAction[argparse.ArgumentParser]) ->
202
233
  "observability, openai, bedrock, claude"
203
234
  ),
204
235
  )
236
+ p.add_argument(
237
+ "--template",
238
+ choices=TEMPLATES,
239
+ default=None,
240
+ help="Scaffold flavour: default (general agent) or coding (repo workflow). "
241
+ "Prompted interactively when omitted.",
242
+ )
205
243
  p.add_argument("--force", action="store_true", help="Overwrite existing scaffold files")
206
244
  p.add_argument("--yes", "-y", action="store_true", help="Skip interactive prompts")
207
245
  p.set_defaults(func=run_new_command)
@@ -120,10 +120,19 @@ def run_validate(args: argparse.Namespace) -> int:
120
120
  category="config",
121
121
  severity="warning",
122
122
  passed=not missing,
123
- message=f"Missing includes: {', '.join(missing)}" if missing else "All includes resolved",
123
+ message=f"Missing includes: {', '.join(missing)}"
124
+ if missing
125
+ else "All includes resolved",
124
126
  )
125
127
  else:
126
- check(report, id="config.includes.resolve", category="config", severity="warning", passed=True, skip=True)
128
+ check(
129
+ report,
130
+ id="config.includes.resolve",
131
+ category="config",
132
+ severity="warning",
133
+ passed=True,
134
+ skip=True,
135
+ )
127
136
 
128
137
  model = merged.get("model") if isinstance(merged.get("model"), dict) else {}
129
138
  provider = str(model.get("provider", "")).strip().lower() if isinstance(model, dict) else ""
@@ -134,7 +143,9 @@ def run_validate(args: argparse.Namespace) -> int:
134
143
  category="config",
135
144
  severity="error",
136
145
  passed=not provider or provider in SUPPORTED_YAML_MODEL_PROVIDERS,
137
- message=f"model.provider '{provider}' is not supported" if provider and provider not in SUPPORTED_YAML_MODEL_PROVIDERS else "",
146
+ message=f"model.provider '{provider}' is not supported"
147
+ if provider and provider not in SUPPORTED_YAML_MODEL_PROVIDERS
148
+ else "",
138
149
  field="model.provider",
139
150
  value=provider,
140
151
  expected=sorted(SUPPORTED_YAML_MODEL_PROVIDERS),
@@ -172,12 +183,23 @@ def run_validate(args: argparse.Namespace) -> int:
172
183
  )
173
184
 
174
185
  flat = _flatten_to_env(merged)
175
- flat.update({k: v for k, v in os.environ.items() if k.startswith(("GCP_", "GOOGLE_", "ANTHROPIC_VERTEX"))})
186
+ flat.update(
187
+ {
188
+ k: v
189
+ for k, v in os.environ.items()
190
+ if k.startswith(("GCP_", "GOOGLE_", "ANTHROPIC_VERTEX"))
191
+ }
192
+ )
176
193
  norm_provider = normalize_model_provider(provider or "gemini")
177
194
  needs_gcp = norm_provider == "vertex_anthropic"
178
195
  gcp_present = any(
179
196
  os.environ.get(k, "").strip() or flat.get(k, "").strip()
180
- for k in ("GCP_PROJECT_ID", "VERTEX_AI_PROJECT_ID", "ANTHROPIC_VERTEX_PROJECT_ID", "GOOGLE_CLOUD_PROJECT")
197
+ for k in (
198
+ "GCP_PROJECT_ID",
199
+ "VERTEX_AI_PROJECT_ID",
200
+ "ANTHROPIC_VERTEX_PROJECT_ID",
201
+ "GOOGLE_CLOUD_PROJECT",
202
+ )
181
203
  )
182
204
  if needs_gcp:
183
205
  check(
@@ -190,11 +212,20 @@ def run_validate(args: argparse.Namespace) -> int:
190
212
  remediation="Set gcp.project_id in monkeybot.yaml or GCP_PROJECT_ID in .env",
191
213
  )
192
214
  else:
193
- check(report, id="gcp.project.required", category="config", severity="error", passed=True, skip=True)
215
+ check(
216
+ report,
217
+ id="gcp.project.required",
218
+ category="config",
219
+ severity="error",
220
+ passed=True,
221
+ skip=True,
222
+ )
194
223
 
195
224
  try:
196
225
  validate_monkeybot_yaml_doc(merged, env=dict(os.environ))
197
- validate_provider_env({**flat, "MODEL_PROVIDER": provider or flat.get("MODEL_PROVIDER", "gemini")})
226
+ validate_provider_env(
227
+ {**flat, "MODEL_PROVIDER": provider or flat.get("MODEL_PROVIDER", "gemini")}
228
+ )
198
229
  except ConfigError as exc:
199
230
  check(
200
231
  report,
@@ -329,7 +360,9 @@ def run_validate(args: argparse.Namespace) -> int:
329
360
  category="mcp",
330
361
  severity="warning",
331
362
  passed=not missing_refs,
332
- message=f"Unresolved MCP env refs: {', '.join(missing_refs)}" if missing_refs else "All MCP env refs set",
363
+ message=f"Unresolved MCP env refs: {', '.join(missing_refs)}"
364
+ if missing_refs
365
+ else "All MCP env refs set",
333
366
  )
334
367
  if args.check_mcp:
335
368
  for name, srv in servers.items():