crabcode 0.1.6__py3-none-win_amd64.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 (205) hide show
  1. crabcode-0.1.6.dist-info/METADATA +63 -0
  2. crabcode-0.1.6.dist-info/RECORD +205 -0
  3. crabcode-0.1.6.dist-info/WHEEL +4 -0
  4. crabcode-0.1.6.dist-info/entry_points.txt +2 -0
  5. crabcode_cli/__init__.py +7 -0
  6. crabcode_cli/__main__.py +4 -0
  7. crabcode_cli/_native/crabcode-computer-use-host.exe +0 -0
  8. crabcode_cli/_native/runtime.json +6 -0
  9. crabcode_cli/app.py +776 -0
  10. crabcode_cli/banner.py +39 -0
  11. crabcode_cli/computer_use.py +286 -0
  12. crabcode_cli/model_manager.py +649 -0
  13. crabcode_cli/model_picker.py +168 -0
  14. crabcode_cli/pipe.py +148 -0
  15. crabcode_cli/render/__init__.py +0 -0
  16. crabcode_cli/render/messages.py +19 -0
  17. crabcode_cli/repl.py +4841 -0
  18. crabcode_cli/session_picker.py +239 -0
  19. crabcode_core/__init__.py +7 -0
  20. crabcode_core/_version.py +3 -0
  21. crabcode_core/_windows_process.py +68 -0
  22. crabcode_core/agent_manager.py +1731 -0
  23. crabcode_core/api/__init__.py +20 -0
  24. crabcode_core/api/anthropic_adapter.py +648 -0
  25. crabcode_core/api/azure_adapter.py +69 -0
  26. crabcode_core/api/base.py +233 -0
  27. crabcode_core/api/codex_adapter.py +1327 -0
  28. crabcode_core/api/error_logging.py +132 -0
  29. crabcode_core/api/gemini_adapter.py +26 -0
  30. crabcode_core/api/model_info.py +257 -0
  31. crabcode_core/api/network.py +62 -0
  32. crabcode_core/api/ollama_adapter.py +27 -0
  33. crabcode_core/api/openai_adapter.py +323 -0
  34. crabcode_core/api/registry.py +58 -0
  35. crabcode_core/command_line.py +18 -0
  36. crabcode_core/compact/__init__.py +17 -0
  37. crabcode_core/compact/compact.py +514 -0
  38. crabcode_core/compact/context_tokens.py +165 -0
  39. crabcode_core/computer_use_mode.py +38 -0
  40. crabcode_core/computer_use_observation.py +144 -0
  41. crabcode_core/config/__init__.py +3 -0
  42. crabcode_core/config/claudemd.py +61 -0
  43. crabcode_core/config/manager.py +237 -0
  44. crabcode_core/config/model_catalog.py +256 -0
  45. crabcode_core/events.py +4896 -0
  46. crabcode_core/file_lock.py +81 -0
  47. crabcode_core/filesystem.py +22 -0
  48. crabcode_core/goal.py +149 -0
  49. crabcode_core/hooks/__init__.py +5 -0
  50. crabcode_core/hooks/manager.py +290 -0
  51. crabcode_core/image_io.py +111 -0
  52. crabcode_core/io_worker.py +211 -0
  53. crabcode_core/logging_utils.py +168 -0
  54. crabcode_core/lsp/__init__.py +6 -0
  55. crabcode_core/lsp/client.py +786 -0
  56. crabcode_core/lsp/diagnostics.py +150 -0
  57. crabcode_core/lsp/manager.py +583 -0
  58. crabcode_core/mcp/__init__.py +4 -0
  59. crabcode_core/mcp/client.py +169 -0
  60. crabcode_core/mcp/config.py +57 -0
  61. crabcode_core/path_validation.py +34 -0
  62. crabcode_core/paths.py +24 -0
  63. crabcode_core/peer/__init__.py +10 -0
  64. crabcode_core/peer/runtime.py +518 -0
  65. crabcode_core/permissions/__init__.py +7 -0
  66. crabcode_core/permissions/ai_reviewer.py +184 -0
  67. crabcode_core/permissions/manager.py +187 -0
  68. crabcode_core/plan/__init__.py +5 -0
  69. crabcode_core/plan/executor.py +270 -0
  70. crabcode_core/plan/types.py +169 -0
  71. crabcode_core/prompts/__init__.py +0 -0
  72. crabcode_core/prompts/blocks.py +10 -0
  73. crabcode_core/prompts/context.py +229 -0
  74. crabcode_core/prompts/library.py +92 -0
  75. crabcode_core/prompts/profile.py +79 -0
  76. crabcode_core/prompts/system.py +356 -0
  77. crabcode_core/prompts/templates.py +104 -0
  78. crabcode_core/query/__init__.py +0 -0
  79. crabcode_core/query/computer_use_history.py +83 -0
  80. crabcode_core/query/loop.py +2446 -0
  81. crabcode_core/query/retry.py +116 -0
  82. crabcode_core/schedule/__init__.py +13 -0
  83. crabcode_core/schedule/manager.py +631 -0
  84. crabcode_core/schedule/models.py +204 -0
  85. crabcode_core/schedule/store.py +558 -0
  86. crabcode_core/schedule/timing.py +287 -0
  87. crabcode_core/session/__init__.py +12 -0
  88. crabcode_core/session/export.py +141 -0
  89. crabcode_core/session/meta_db.py +684 -0
  90. crabcode_core/session/storage.py +2032 -0
  91. crabcode_core/session/title_gen.py +52 -0
  92. crabcode_core/skills/__init__.py +6 -0
  93. crabcode_core/skills/context.py +23 -0
  94. crabcode_core/skills/loader.py +214 -0
  95. crabcode_core/skills/matcher.py +203 -0
  96. crabcode_core/snapshot/__init__.py +21 -0
  97. crabcode_core/snapshot/snapshot.py +436 -0
  98. crabcode_core/snapshot/tracker.py +359 -0
  99. crabcode_core/subprocess_utils.py +260 -0
  100. crabcode_core/team/__init__.py +27 -0
  101. crabcode_core/team/bridge.py +66 -0
  102. crabcode_core/team/inbox.py +237 -0
  103. crabcode_core/team/manager.py +1660 -0
  104. crabcode_core/team/message_bus.py +821 -0
  105. crabcode_core/team/models.py +191 -0
  106. crabcode_core/team/recovery.py +192 -0
  107. crabcode_core/text_io.py +52 -0
  108. crabcode_core/tools/__init__.py +112 -0
  109. crabcode_core/tools/_input_helpers.py +39 -0
  110. crabcode_core/tools/_input_schema.py +130 -0
  111. crabcode_core/tools/agent.py +413 -0
  112. crabcode_core/tools/apply_patch.py +587 -0
  113. crabcode_core/tools/ask_user.py +122 -0
  114. crabcode_core/tools/bash.py +154 -0
  115. crabcode_core/tools/browser.py +678 -0
  116. crabcode_core/tools/checklist.py +241 -0
  117. crabcode_core/tools/checkpoint.py +78 -0
  118. crabcode_core/tools/computer_use.py +599 -0
  119. crabcode_core/tools/diff_utils.py +123 -0
  120. crabcode_core/tools/file_edit.py +240 -0
  121. crabcode_core/tools/file_read.py +187 -0
  122. crabcode_core/tools/file_write.py +154 -0
  123. crabcode_core/tools/glob.py +123 -0
  124. crabcode_core/tools/goal.py +134 -0
  125. crabcode_core/tools/grep.py +247 -0
  126. crabcode_core/tools/image.py +173 -0
  127. crabcode_core/tools/image_generate.py +115 -0
  128. crabcode_core/tools/lint.py +388 -0
  129. crabcode_core/tools/loading.py +187 -0
  130. crabcode_core/tools/memory.py +456 -0
  131. crabcode_core/tools/monitor.py +677 -0
  132. crabcode_core/tools/peer.py +103 -0
  133. crabcode_core/tools/revert.py +90 -0
  134. crabcode_core/tools/schedule.py +683 -0
  135. crabcode_core/tools/skill.py +108 -0
  136. crabcode_core/tools/switch_mode.py +213 -0
  137. crabcode_core/tools/team.py +329 -0
  138. crabcode_core/tools/tool_search.py +64 -0
  139. crabcode_core/tools/web_search.py +444 -0
  140. crabcode_core/turn_details.py +64 -0
  141. crabcode_core/types/__init__.py +95 -0
  142. crabcode_core/types/config.py +434 -0
  143. crabcode_core/types/event.py +334 -0
  144. crabcode_core/types/message.py +263 -0
  145. crabcode_core/types/tool.py +208 -0
  146. crabcode_core/usage.py +289 -0
  147. crabcode_core/utf8_sanitize.py +29 -0
  148. crabcode_debugger/__init__.py +5 -0
  149. crabcode_debugger/adapters.py +332 -0
  150. crabcode_debugger/dap.py +304 -0
  151. crabcode_debugger/memory.py +1806 -0
  152. crabcode_debugger/process.py +707 -0
  153. crabcode_debugger/runtime.py +58 -0
  154. crabcode_debugger/sessions.py +387 -0
  155. crabcode_debugger/tool.py +731 -0
  156. crabcode_gateway/__init__.py +18 -0
  157. crabcode_gateway/acp/__init__.py +13 -0
  158. crabcode_gateway/acp/agent.py +1581 -0
  159. crabcode_gateway/acp/session.py +114 -0
  160. crabcode_gateway/acp/transport.py +39 -0
  161. crabcode_gateway/acp/types.py +90 -0
  162. crabcode_gateway/adapter.py +30 -0
  163. crabcode_gateway/auth.py +139 -0
  164. crabcode_gateway/computer_use.py +364 -0
  165. crabcode_gateway/document_engine.py +637 -0
  166. crabcode_gateway/document_worker.py +301 -0
  167. crabcode_gateway/event_bus.py +655 -0
  168. crabcode_gateway/grpc/__init__.py +0 -0
  169. crabcode_gateway/grpc/crabcode_pb2.py +70 -0
  170. crabcode_gateway/grpc/crabcode_pb2_grpc.py +474 -0
  171. crabcode_gateway/grpc/proto/crabcode.proto +160 -0
  172. crabcode_gateway/grpc/server.py +765 -0
  173. crabcode_gateway/middleware.py +344 -0
  174. crabcode_gateway/protocol.py +17 -0
  175. crabcode_gateway/routes/__init__.py +0 -0
  176. crabcode_gateway/routes/agent.py +392 -0
  177. crabcode_gateway/routes/auth.py +150 -0
  178. crabcode_gateway/routes/computer_use.py +101 -0
  179. crabcode_gateway/routes/config.py +1698 -0
  180. crabcode_gateway/routes/document.py +1810 -0
  181. crabcode_gateway/routes/event.py +3801 -0
  182. crabcode_gateway/routes/git_review.py +232 -0
  183. crabcode_gateway/routes/health.py +14 -0
  184. crabcode_gateway/routes/peer.py +78 -0
  185. crabcode_gateway/routes/permission.py +121 -0
  186. crabcode_gateway/routes/schedule.py +300 -0
  187. crabcode_gateway/routes/session.py +1496 -0
  188. crabcode_gateway/routes/snapshot.py +211 -0
  189. crabcode_gateway/routes/tasks.py +318 -0
  190. crabcode_gateway/routes/team.py +392 -0
  191. crabcode_gateway/routes/usage.py +49 -0
  192. crabcode_gateway/routes/workspace.py +299 -0
  193. crabcode_gateway/safe_file.py +63 -0
  194. crabcode_gateway/schemas.py +1820 -0
  195. crabcode_gateway/server.py +514 -0
  196. crabcode_gateway/session_registry.py +36 -0
  197. crabcode_gateway/task_registry.py +708 -0
  198. crabcode_gateway/turn_details.py +60 -0
  199. crabcode_search/__init__.py +11 -0
  200. crabcode_search/background.py +368 -0
  201. crabcode_search/chunker.py +517 -0
  202. crabcode_search/embedder.py +497 -0
  203. crabcode_search/indexer.py +215 -0
  204. crabcode_search/store.py +222 -0
  205. crabcode_search/tool.py +268 -0
@@ -0,0 +1,649 @@
1
+ """Guided terminal menus for adding and deleting model catalog entries."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ from dataclasses import dataclass
7
+ from typing import Any, Callable, Literal, Protocol, Sequence, cast
8
+
9
+ from prompt_toolkit import PromptSession
10
+ from prompt_toolkit.application import Application
11
+ from prompt_toolkit.buffer import Buffer
12
+ from prompt_toolkit.data_structures import Point
13
+ from prompt_toolkit.formatted_text import HTML
14
+ from prompt_toolkit.key_binding import KeyBindings
15
+ from prompt_toolkit.layout import HSplit, Layout, VSplit, Window
16
+ from prompt_toolkit.layout.controls import BufferControl, FormattedTextControl
17
+
18
+ from crabcode_cli.session_picker import PICKER_STYLE, _plain
19
+ from crabcode_core.config.manager import ConfigManager
20
+ from crabcode_core.config.model_catalog import (
21
+ SETTINGS_SOURCE_LABELS,
22
+ WRITABLE_SETTINGS_SOURCES,
23
+ CatalogKind,
24
+ CatalogMutationResult,
25
+ ModelCatalogError,
26
+ WritableSettingsSource,
27
+ add_catalog_entry,
28
+ catalog_names,
29
+ delete_catalog_entry,
30
+ list_catalog_definitions,
31
+ referencing_models,
32
+ settings_source_path,
33
+ validate_catalog_name,
34
+ )
35
+ from crabcode_core.events import CoreSession
36
+
37
+
38
+ ManagementAction = Literal["add", "delete"]
39
+
40
+ _SENSITIVE_CONFIG_KEYS = (
41
+ "api_key",
42
+ "apikey",
43
+ "authorization",
44
+ "cookie",
45
+ "password",
46
+ "secret",
47
+ "token",
48
+ )
49
+
50
+
51
+ @dataclass(frozen=True)
52
+ class MenuOption:
53
+ value: str
54
+ label: str
55
+ hint: str = ""
56
+
57
+
58
+ class Prompter(Protocol):
59
+ async def select(
60
+ self,
61
+ message: str,
62
+ options: Sequence[MenuOption],
63
+ *,
64
+ searchable: bool = True,
65
+ initial: str | None = None,
66
+ ) -> str | None: ...
67
+
68
+ async def text(self, message: str, *, initial: str = "") -> str | None: ...
69
+
70
+ async def confirm(self, message: str, *, default: bool = False) -> bool | None: ...
71
+
72
+ def note(self, title: str, body: str) -> None: ...
73
+
74
+ def error(self, message: str) -> None: ...
75
+
76
+
77
+ class SearchableMenu:
78
+ """Small OpenClaw-style searchable select with labels and contextual hints."""
79
+
80
+ def __init__(
81
+ self,
82
+ title: str,
83
+ message: str,
84
+ options: Sequence[MenuOption],
85
+ *,
86
+ searchable: bool = True,
87
+ initial: str | None = None,
88
+ ) -> None:
89
+ self.title = title
90
+ self.message = message
91
+ self.options = list(options)
92
+ self.searchable = searchable
93
+ self.initial = initial
94
+ self.matches = self.options.copy()
95
+ self.selected = next(
96
+ (index for index, option in enumerate(self.matches) if option.value == initial),
97
+ 0,
98
+ )
99
+ self.query = Buffer(on_text_changed=lambda _: self.refresh())
100
+ self.search = BufferControl(buffer=self.query)
101
+ self.list_control = FormattedTextControl(
102
+ self._list,
103
+ focusable=not searchable,
104
+ get_cursor_position=lambda: Point(x=0, y=self.selected),
105
+ )
106
+ self.list_window = Window(
107
+ self.list_control,
108
+ wrap_lines=False,
109
+ always_hide_cursor=True,
110
+ )
111
+ bindings = KeyBindings()
112
+
113
+ @bindings.add("escape", eager=True)
114
+ @bindings.add("c-c", eager=True)
115
+ @bindings.add("c-d", eager=True)
116
+ def cancel(event: Any) -> None:
117
+ event.app.exit(result=None)
118
+
119
+ @bindings.add("enter", eager=True)
120
+ def confirm(event: Any) -> None:
121
+ if self.matches:
122
+ event.app.exit(result=self.matches[self.selected].value)
123
+
124
+ @bindings.add("up")
125
+ @bindings.add("down")
126
+ @bindings.add("pageup")
127
+ @bindings.add("pagedown")
128
+ def navigate(event: Any) -> None:
129
+ page = (
130
+ max(1, self.list_window.render_info.window_height - 1)
131
+ if self.list_window.render_info
132
+ else 10
133
+ )
134
+ delta = {
135
+ "up": -1,
136
+ "down": 1,
137
+ "pageup": -page,
138
+ "pagedown": page,
139
+ }[event.key_sequence[0].key]
140
+ self.selected = max(0, min(len(self.matches) - 1, self.selected + delta))
141
+
142
+ content: list[Any] = [
143
+ Window(
144
+ FormattedTextControl([("class:accent", f" ╭─ CrabCode · {_plain(title)}")]),
145
+ height=1,
146
+ ),
147
+ Window(
148
+ FormattedTextControl([("class:hint", f" {_plain(message)}")]),
149
+ height=1,
150
+ wrap_lines=False,
151
+ ),
152
+ Window(height=1),
153
+ ]
154
+ if searchable:
155
+ content.extend(
156
+ [
157
+ VSplit(
158
+ [
159
+ Window(
160
+ FormattedTextControl([("class:accent", " Search ❯ ")]),
161
+ width=11,
162
+ ),
163
+ Window(self.search, height=1, wrap_lines=False),
164
+ ],
165
+ height=1,
166
+ ),
167
+ Window(height=1),
168
+ ]
169
+ )
170
+ content.extend(
171
+ [
172
+ self.list_window,
173
+ Window(FormattedTextControl(self._details), height=2, wrap_lines=True),
174
+ Window(char="─", height=1, style="class:hint"),
175
+ Window(FormattedTextControl(self._footer), height=2, wrap_lines=True),
176
+ ]
177
+ )
178
+ focused = self.search if searchable else self.list_control
179
+ self.app: Application[str | None] = Application(
180
+ layout=Layout(HSplit(content), focused_element=focused),
181
+ key_bindings=bindings,
182
+ full_screen=True,
183
+ erase_when_done=True,
184
+ style=PICKER_STYLE,
185
+ )
186
+
187
+ def refresh(self) -> None:
188
+ selected_value = (
189
+ self.matches[self.selected].value
190
+ if self.matches and self.selected < len(self.matches)
191
+ else self.initial
192
+ )
193
+ terms = self.query.text.casefold().split()
194
+ self.matches = [
195
+ option
196
+ for option in self.options
197
+ if all(
198
+ term
199
+ in f"{option.label} {option.value} {option.hint}".casefold()
200
+ for term in terms
201
+ )
202
+ ]
203
+ self.selected = next(
204
+ (
205
+ index
206
+ for index, option in enumerate(self.matches)
207
+ if option.value == selected_value
208
+ ),
209
+ 0,
210
+ )
211
+ self.app.invalidate()
212
+
213
+ def _list(self) -> list[tuple[str, str]]:
214
+ if not self.options:
215
+ return [("class:hint", " No choices are available.")]
216
+ if not self.matches:
217
+ return [("class:hint", " No matching choices. Clear the search to continue.")]
218
+ fragments: list[tuple[str, str]] = []
219
+ for index, option in enumerate(self.matches):
220
+ style = "class:selected" if index == self.selected else ""
221
+ fragments.extend(
222
+ [
223
+ (style, " ❯ " if index == self.selected else " "),
224
+ (style, _plain(option.label)),
225
+ ("", "\n" if index < len(self.matches) - 1 else ""),
226
+ ]
227
+ )
228
+ return fragments
229
+
230
+ def _details(self) -> list[tuple[str, str]]:
231
+ if not self.matches:
232
+ return []
233
+ option = self.matches[self.selected]
234
+ return [
235
+ ("class:hint", f" {_plain(option.label)}\n {_plain(option.hint)}")
236
+ ]
237
+
238
+ def _footer(self) -> list[tuple[str, str]]:
239
+ count = len(self.matches)
240
+ search_help = " · Type to search" if self.searchable else ""
241
+ return [
242
+ (
243
+ "class:hint",
244
+ f" ↑↓ browse · PgUp/PgDn page · Enter select · Esc/Ctrl+C cancel"
245
+ f" {self.selected + 1 if count else 0}/{count}\n {search_help.lstrip(' ·')}",
246
+ )
247
+ ]
248
+
249
+ async def run(self) -> str | None:
250
+ if not self.options:
251
+ return None
252
+ return await self.app.run_async()
253
+
254
+
255
+ class TerminalPrompter:
256
+ def __init__(self, writer: Callable[[str], None] | None = None) -> None:
257
+ self._writer = writer or print
258
+
259
+ async def select(
260
+ self,
261
+ message: str,
262
+ options: Sequence[MenuOption],
263
+ *,
264
+ searchable: bool = True,
265
+ initial: str | None = None,
266
+ ) -> str | None:
267
+ return await SearchableMenu(
268
+ "Model management",
269
+ message,
270
+ options,
271
+ searchable=searchable,
272
+ initial=initial,
273
+ ).run()
274
+
275
+ async def text(self, message: str, *, initial: str = "") -> str | None:
276
+ prompt: PromptSession[str] = PromptSession()
277
+ try:
278
+ return (
279
+ await prompt.prompt_async(
280
+ HTML(f"<b><ansicyan>{message} ❯ </ansicyan></b>"),
281
+ default=initial,
282
+ )
283
+ ).strip()
284
+ except (EOFError, KeyboardInterrupt):
285
+ return None
286
+
287
+ async def confirm(self, message: str, *, default: bool = False) -> bool | None:
288
+ suffix = "[Y/n]" if default else "[y/N]"
289
+ while True:
290
+ answer = await self.text(f"{message} {suffix}")
291
+ if answer is None:
292
+ return None
293
+ if not answer:
294
+ return default
295
+ if answer.casefold() in {"y", "yes"}:
296
+ return True
297
+ if answer.casefold() in {"n", "no"}:
298
+ return False
299
+ self.error("Please answer y or n.")
300
+
301
+ def note(self, title: str, body: str) -> None:
302
+ self._writer(f"\n {title}\n{_indent(body)}\n")
303
+
304
+ def error(self, message: str) -> None:
305
+ self._writer(f" Error: {message}")
306
+
307
+
308
+ class _Cancelled(Exception):
309
+ pass
310
+
311
+
312
+ _PROVIDERS: tuple[MenuOption, ...] = (
313
+ MenuOption("anthropic", "Anthropic", "Anthropic Messages API"),
314
+ MenuOption("openai", "OpenAI", "OpenAI-compatible Chat Completions"),
315
+ MenuOption("codex", "Codex", "OpenAI Responses / Codex OAuth"),
316
+ MenuOption("router", "Router / custom endpoint", "Choose the wire format separately"),
317
+ MenuOption("ollama", "Ollama", "Local or remote Ollama API"),
318
+ MenuOption("gemini", "Gemini", "Google Gemini API"),
319
+ MenuOption("azure", "Azure OpenAI", "Azure deployment settings can use advanced JSON"),
320
+ MenuOption("bedrock", "Amazon Bedrock", "Anthropic models through Bedrock"),
321
+ MenuOption("vertex", "Google Vertex AI", "Anthropic models through Vertex AI"),
322
+ )
323
+
324
+ _FORMATS: tuple[MenuOption, ...] = tuple(
325
+ MenuOption(value, label, "Router request/response format")
326
+ for value, label in (
327
+ ("openai", "OpenAI"),
328
+ ("anthropic", "Anthropic"),
329
+ ("codex", "Codex Responses"),
330
+ ("ollama", "Ollama"),
331
+ ("gemini", "Gemini"),
332
+ ("azure", "Azure OpenAI"),
333
+ )
334
+ )
335
+
336
+
337
+ async def manage_model_catalog(
338
+ session: CoreSession,
339
+ action: ManagementAction,
340
+ kind: CatalogKind | None = None,
341
+ *,
342
+ prompter: Prompter | None = None,
343
+ ) -> CatalogMutationResult | None:
344
+ """Run the guided add/delete flow and refresh the current session catalog."""
345
+ active_prompter = prompter or TerminalPrompter()
346
+ try:
347
+ selected_kind = kind or await _choose_kind(action, active_prompter)
348
+ if selected_kind is None:
349
+ return None
350
+ if action == "add":
351
+ result = await _add_entry(session, selected_kind, active_prompter)
352
+ else:
353
+ result = await _delete_entry(session, selected_kind, active_prompter)
354
+ if result is not None:
355
+ session.reload_model_catalog()
356
+ return result
357
+ except _Cancelled:
358
+ return None
359
+
360
+
361
+ async def _choose_kind(action: ManagementAction, prompter: Prompter) -> CatalogKind | None:
362
+ value = await prompter.select(
363
+ "What do you want to add?" if action == "add" else "What do you want to delete?",
364
+ (
365
+ MenuOption("model", "Model", "A named model profile shown by /model"),
366
+ MenuOption("group", "Group", "Shared provider and API settings inherited by models"),
367
+ ),
368
+ searchable=False,
369
+ initial="model",
370
+ )
371
+ return cast(CatalogKind | None, value)
372
+
373
+
374
+ async def _choose_source(cwd: str, prompter: Prompter) -> WritableSettingsSource:
375
+ options = [
376
+ MenuOption(
377
+ source,
378
+ SETTINGS_SOURCE_LABELS[source],
379
+ str(settings_source_path(cwd, source)),
380
+ )
381
+ for source in WRITABLE_SETTINGS_SOURCES
382
+ ]
383
+ value = await prompter.select(
384
+ "Where should this configuration be saved?",
385
+ options,
386
+ searchable=False,
387
+ initial="projectSettings",
388
+ )
389
+ if value is None:
390
+ raise _Cancelled
391
+ return cast(WritableSettingsSource, value)
392
+
393
+
394
+ async def _add_entry(
395
+ session: CoreSession,
396
+ kind: CatalogKind,
397
+ prompter: Prompter,
398
+ ) -> CatalogMutationResult | None:
399
+ source = await _choose_source(session.cwd, prompter)
400
+ name = await _prompt_new_name(session.cwd, kind, prompter)
401
+ config = await _prompt_config(session.cwd, kind, prompter)
402
+ preview = json.dumps(_redact_config(config), indent=2, ensure_ascii=False)
403
+ prompter.note(
404
+ f'Add {kind} "{name}" to {SETTINGS_SOURCE_LABELS[source]}',
405
+ preview,
406
+ )
407
+ confirmed = await prompter.confirm("Save this configuration?", default=True)
408
+ if not confirmed:
409
+ return None
410
+ return add_catalog_entry(session.cwd, source, kind, name, config)
411
+
412
+
413
+ async def _prompt_new_name(cwd: str, kind: CatalogKind, prompter: Prompter) -> str:
414
+ existing = catalog_names(cwd, kind)
415
+ while True:
416
+ value = await prompter.text(
417
+ "Model profile name" if kind == "model" else "Group name"
418
+ )
419
+ if value is None:
420
+ raise _Cancelled
421
+ try:
422
+ name = validate_catalog_name(value)
423
+ except ModelCatalogError as exc:
424
+ prompter.error(str(exc))
425
+ continue
426
+ if name in existing:
427
+ prompter.error(
428
+ f'{kind.title()} "{name}" already exists. /add never overwrites entries.'
429
+ )
430
+ continue
431
+ return name
432
+
433
+
434
+ async def _prompt_config(
435
+ cwd: str,
436
+ kind: CatalogKind,
437
+ prompter: Prompter,
438
+ ) -> dict[str, Any]:
439
+ config: dict[str, Any] = {}
440
+ group_name: str | None = None
441
+ if kind == "model":
442
+ settings = ConfigManager(cwd=cwd).load()
443
+ group_options = [
444
+ MenuOption("__none__", "No group", "Configure the provider on this model")
445
+ ]
446
+ for name, group in settings.groups.items():
447
+ group_options.append(
448
+ MenuOption(
449
+ name,
450
+ name,
451
+ _config_hint(group.model_dump(exclude_none=True)),
452
+ )
453
+ )
454
+ selected_group = await prompter.select(
455
+ "Select a configuration group",
456
+ group_options,
457
+ searchable=True,
458
+ initial="__none__",
459
+ )
460
+ if selected_group is None:
461
+ raise _Cancelled
462
+ group_name = None if selected_group == "__none__" else selected_group
463
+ model_id = await _required_text("Provider model ID", prompter)
464
+ config["model"] = model_id
465
+
466
+ provider_options = list(_PROVIDERS)
467
+ provider_initial = "anthropic"
468
+ if kind == "group":
469
+ provider_options.insert(
470
+ 0,
471
+ MenuOption("__none__", "No provider", "Use advanced JSON for shared non-provider fields"),
472
+ )
473
+ provider_initial = "__none__"
474
+ elif group_name is not None:
475
+ provider_options.insert(
476
+ 0,
477
+ MenuOption("__inherit__", "Inherit from group", f"Use provider settings from {group_name}"),
478
+ )
479
+ provider_initial = "__inherit__"
480
+
481
+ provider = await prompter.select(
482
+ "Select a provider",
483
+ provider_options,
484
+ searchable=True,
485
+ initial=provider_initial,
486
+ )
487
+ if provider is None:
488
+ raise _Cancelled
489
+ if provider not in {"__none__", "__inherit__"}:
490
+ config["provider"] = provider
491
+ base_url = await prompter.text("Base URL (optional)")
492
+ if base_url is None:
493
+ raise _Cancelled
494
+ if base_url:
495
+ config["base_url"] = base_url
496
+ api_key_env = await prompter.text("API key environment variable (optional)")
497
+ if api_key_env is None:
498
+ raise _Cancelled
499
+ if api_key_env:
500
+ config["api_key_env"] = api_key_env
501
+ if provider == "router":
502
+ api_format = await prompter.select(
503
+ "Select the router API format",
504
+ _FORMATS,
505
+ searchable=False,
506
+ initial="openai",
507
+ )
508
+ if api_format is None:
509
+ raise _Cancelled
510
+ config["format"] = api_format
511
+
512
+ advanced = await _prompt_advanced_json(prompter)
513
+ config.update(advanced)
514
+ if kind == "model" and group_name is not None:
515
+ config["group"] = group_name
516
+ if kind == "group":
517
+ config.pop("group", None)
518
+ return config
519
+
520
+
521
+ async def _required_text(message: str, prompter: Prompter) -> str:
522
+ while True:
523
+ value = await prompter.text(message)
524
+ if value is None:
525
+ raise _Cancelled
526
+ if value:
527
+ return value
528
+ prompter.error(f"{message} is required.")
529
+
530
+
531
+ async def _prompt_advanced_json(prompter: Prompter) -> dict[str, Any]:
532
+ while True:
533
+ raw = await prompter.text("Advanced ApiConfig JSON (optional)")
534
+ if raw is None:
535
+ raise _Cancelled
536
+ if not raw:
537
+ return {}
538
+ try:
539
+ value = json.loads(raw)
540
+ except json.JSONDecodeError as exc:
541
+ prompter.error(f"Invalid JSON: {exc.msg} at column {exc.colno}.")
542
+ continue
543
+ if not isinstance(value, dict):
544
+ prompter.error("Advanced configuration must be a JSON object.")
545
+ continue
546
+ return value
547
+
548
+
549
+ async def _delete_entry(
550
+ session: CoreSession,
551
+ kind: CatalogKind,
552
+ prompter: Prompter,
553
+ ) -> CatalogMutationResult | None:
554
+ definitions = list_catalog_definitions(session.cwd, kind, writable_only=True)
555
+ if not definitions:
556
+ raise ModelCatalogError(
557
+ f"No deletable {kind}s are defined in user, project, or local settings."
558
+ )
559
+ options = [
560
+ MenuOption(
561
+ str(index),
562
+ definition.name,
563
+ f"{SETTINGS_SOURCE_LABELS[definition.source]} · "
564
+ f"{_config_hint(definition.config)}",
565
+ )
566
+ for index, definition in enumerate(definitions)
567
+ ]
568
+ selected = await prompter.select(
569
+ f"Select a {kind} to delete",
570
+ options,
571
+ searchable=True,
572
+ )
573
+ if selected is None:
574
+ return None
575
+ definition = definitions[int(selected)]
576
+ _protect_active_selection(session, definition.kind, definition.name)
577
+
578
+ warnings: list[str] = [
579
+ f"Source: {SETTINGS_SOURCE_LABELS[definition.source]}",
580
+ f"File: {definition.path}",
581
+ ]
582
+ if kind == "model" and session.settings.default_model == definition.name:
583
+ warnings.append("This is the configured default model.")
584
+ if kind == "group":
585
+ references = referencing_models(session.cwd, definition.name)
586
+ if references:
587
+ warnings.append(
588
+ "Models that will lose inherited settings: " + ", ".join(references)
589
+ )
590
+ prompter.note(f'Delete {kind} "{definition.name}"', "\n".join(warnings))
591
+ confirmed = await prompter.confirm(
592
+ "Delete only this definition from the selected layer?",
593
+ default=False,
594
+ )
595
+ if not confirmed:
596
+ return None
597
+ return delete_catalog_entry(
598
+ session.cwd,
599
+ cast(WritableSettingsSource, definition.source),
600
+ kind,
601
+ definition.name,
602
+ )
603
+
604
+
605
+ def _protect_active_selection(session: CoreSession, kind: CatalogKind, name: str) -> None:
606
+ current = getattr(session, "_current_model_name", None)
607
+ if current is None:
608
+ return
609
+ if kind == "model" and current == name:
610
+ raise ModelCatalogError(
611
+ f'Cannot delete active model "{name}". Switch with /model first.'
612
+ )
613
+ if kind == "group" and session.settings.get_api_config(current).group == name:
614
+ raise ModelCatalogError(
615
+ f'Cannot delete group "{name}" while active model "{current}" inherits it. '
616
+ "Switch to a model outside this group first."
617
+ )
618
+
619
+
620
+ def _config_hint(config: dict[str, Any]) -> str:
621
+ parts = [
622
+ str(config.get(key))
623
+ for key in ("group", "provider", "model", "base_url")
624
+ if config.get(key)
625
+ ]
626
+ return " · ".join(parts) if parts else "Empty/shared configuration"
627
+
628
+
629
+ def _redact_config(value: Any, key: str = "") -> Any:
630
+ normalized_key = key.casefold().replace("-", "_")
631
+ is_reference = normalized_key.endswith("_env") or normalized_key.endswith("_path")
632
+ if (
633
+ key
634
+ and not is_reference
635
+ and any(part in normalized_key for part in _SENSITIVE_CONFIG_KEYS)
636
+ ):
637
+ return "[redacted]"
638
+ if isinstance(value, dict):
639
+ return {
640
+ child_key: _redact_config(child, str(child_key))
641
+ for child_key, child in value.items()
642
+ }
643
+ if isinstance(value, list):
644
+ return [_redact_config(child) for child in value]
645
+ return value
646
+
647
+
648
+ def _indent(value: str) -> str:
649
+ return "\n".join(f" {line}" for line in value.splitlines())