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.
- crabcode-0.1.6.dist-info/METADATA +63 -0
- crabcode-0.1.6.dist-info/RECORD +205 -0
- crabcode-0.1.6.dist-info/WHEEL +4 -0
- crabcode-0.1.6.dist-info/entry_points.txt +2 -0
- crabcode_cli/__init__.py +7 -0
- crabcode_cli/__main__.py +4 -0
- crabcode_cli/_native/crabcode-computer-use-host.exe +0 -0
- crabcode_cli/_native/runtime.json +6 -0
- crabcode_cli/app.py +776 -0
- crabcode_cli/banner.py +39 -0
- crabcode_cli/computer_use.py +286 -0
- crabcode_cli/model_manager.py +649 -0
- crabcode_cli/model_picker.py +168 -0
- crabcode_cli/pipe.py +148 -0
- crabcode_cli/render/__init__.py +0 -0
- crabcode_cli/render/messages.py +19 -0
- crabcode_cli/repl.py +4841 -0
- crabcode_cli/session_picker.py +239 -0
- crabcode_core/__init__.py +7 -0
- crabcode_core/_version.py +3 -0
- crabcode_core/_windows_process.py +68 -0
- crabcode_core/agent_manager.py +1731 -0
- crabcode_core/api/__init__.py +20 -0
- crabcode_core/api/anthropic_adapter.py +648 -0
- crabcode_core/api/azure_adapter.py +69 -0
- crabcode_core/api/base.py +233 -0
- crabcode_core/api/codex_adapter.py +1327 -0
- crabcode_core/api/error_logging.py +132 -0
- crabcode_core/api/gemini_adapter.py +26 -0
- crabcode_core/api/model_info.py +257 -0
- crabcode_core/api/network.py +62 -0
- crabcode_core/api/ollama_adapter.py +27 -0
- crabcode_core/api/openai_adapter.py +323 -0
- crabcode_core/api/registry.py +58 -0
- crabcode_core/command_line.py +18 -0
- crabcode_core/compact/__init__.py +17 -0
- crabcode_core/compact/compact.py +514 -0
- crabcode_core/compact/context_tokens.py +165 -0
- crabcode_core/computer_use_mode.py +38 -0
- crabcode_core/computer_use_observation.py +144 -0
- crabcode_core/config/__init__.py +3 -0
- crabcode_core/config/claudemd.py +61 -0
- crabcode_core/config/manager.py +237 -0
- crabcode_core/config/model_catalog.py +256 -0
- crabcode_core/events.py +4896 -0
- crabcode_core/file_lock.py +81 -0
- crabcode_core/filesystem.py +22 -0
- crabcode_core/goal.py +149 -0
- crabcode_core/hooks/__init__.py +5 -0
- crabcode_core/hooks/manager.py +290 -0
- crabcode_core/image_io.py +111 -0
- crabcode_core/io_worker.py +211 -0
- crabcode_core/logging_utils.py +168 -0
- crabcode_core/lsp/__init__.py +6 -0
- crabcode_core/lsp/client.py +786 -0
- crabcode_core/lsp/diagnostics.py +150 -0
- crabcode_core/lsp/manager.py +583 -0
- crabcode_core/mcp/__init__.py +4 -0
- crabcode_core/mcp/client.py +169 -0
- crabcode_core/mcp/config.py +57 -0
- crabcode_core/path_validation.py +34 -0
- crabcode_core/paths.py +24 -0
- crabcode_core/peer/__init__.py +10 -0
- crabcode_core/peer/runtime.py +518 -0
- crabcode_core/permissions/__init__.py +7 -0
- crabcode_core/permissions/ai_reviewer.py +184 -0
- crabcode_core/permissions/manager.py +187 -0
- crabcode_core/plan/__init__.py +5 -0
- crabcode_core/plan/executor.py +270 -0
- crabcode_core/plan/types.py +169 -0
- crabcode_core/prompts/__init__.py +0 -0
- crabcode_core/prompts/blocks.py +10 -0
- crabcode_core/prompts/context.py +229 -0
- crabcode_core/prompts/library.py +92 -0
- crabcode_core/prompts/profile.py +79 -0
- crabcode_core/prompts/system.py +356 -0
- crabcode_core/prompts/templates.py +104 -0
- crabcode_core/query/__init__.py +0 -0
- crabcode_core/query/computer_use_history.py +83 -0
- crabcode_core/query/loop.py +2446 -0
- crabcode_core/query/retry.py +116 -0
- crabcode_core/schedule/__init__.py +13 -0
- crabcode_core/schedule/manager.py +631 -0
- crabcode_core/schedule/models.py +204 -0
- crabcode_core/schedule/store.py +558 -0
- crabcode_core/schedule/timing.py +287 -0
- crabcode_core/session/__init__.py +12 -0
- crabcode_core/session/export.py +141 -0
- crabcode_core/session/meta_db.py +684 -0
- crabcode_core/session/storage.py +2032 -0
- crabcode_core/session/title_gen.py +52 -0
- crabcode_core/skills/__init__.py +6 -0
- crabcode_core/skills/context.py +23 -0
- crabcode_core/skills/loader.py +214 -0
- crabcode_core/skills/matcher.py +203 -0
- crabcode_core/snapshot/__init__.py +21 -0
- crabcode_core/snapshot/snapshot.py +436 -0
- crabcode_core/snapshot/tracker.py +359 -0
- crabcode_core/subprocess_utils.py +260 -0
- crabcode_core/team/__init__.py +27 -0
- crabcode_core/team/bridge.py +66 -0
- crabcode_core/team/inbox.py +237 -0
- crabcode_core/team/manager.py +1660 -0
- crabcode_core/team/message_bus.py +821 -0
- crabcode_core/team/models.py +191 -0
- crabcode_core/team/recovery.py +192 -0
- crabcode_core/text_io.py +52 -0
- crabcode_core/tools/__init__.py +112 -0
- crabcode_core/tools/_input_helpers.py +39 -0
- crabcode_core/tools/_input_schema.py +130 -0
- crabcode_core/tools/agent.py +413 -0
- crabcode_core/tools/apply_patch.py +587 -0
- crabcode_core/tools/ask_user.py +122 -0
- crabcode_core/tools/bash.py +154 -0
- crabcode_core/tools/browser.py +678 -0
- crabcode_core/tools/checklist.py +241 -0
- crabcode_core/tools/checkpoint.py +78 -0
- crabcode_core/tools/computer_use.py +599 -0
- crabcode_core/tools/diff_utils.py +123 -0
- crabcode_core/tools/file_edit.py +240 -0
- crabcode_core/tools/file_read.py +187 -0
- crabcode_core/tools/file_write.py +154 -0
- crabcode_core/tools/glob.py +123 -0
- crabcode_core/tools/goal.py +134 -0
- crabcode_core/tools/grep.py +247 -0
- crabcode_core/tools/image.py +173 -0
- crabcode_core/tools/image_generate.py +115 -0
- crabcode_core/tools/lint.py +388 -0
- crabcode_core/tools/loading.py +187 -0
- crabcode_core/tools/memory.py +456 -0
- crabcode_core/tools/monitor.py +677 -0
- crabcode_core/tools/peer.py +103 -0
- crabcode_core/tools/revert.py +90 -0
- crabcode_core/tools/schedule.py +683 -0
- crabcode_core/tools/skill.py +108 -0
- crabcode_core/tools/switch_mode.py +213 -0
- crabcode_core/tools/team.py +329 -0
- crabcode_core/tools/tool_search.py +64 -0
- crabcode_core/tools/web_search.py +444 -0
- crabcode_core/turn_details.py +64 -0
- crabcode_core/types/__init__.py +95 -0
- crabcode_core/types/config.py +434 -0
- crabcode_core/types/event.py +334 -0
- crabcode_core/types/message.py +263 -0
- crabcode_core/types/tool.py +208 -0
- crabcode_core/usage.py +289 -0
- crabcode_core/utf8_sanitize.py +29 -0
- crabcode_debugger/__init__.py +5 -0
- crabcode_debugger/adapters.py +332 -0
- crabcode_debugger/dap.py +304 -0
- crabcode_debugger/memory.py +1806 -0
- crabcode_debugger/process.py +707 -0
- crabcode_debugger/runtime.py +58 -0
- crabcode_debugger/sessions.py +387 -0
- crabcode_debugger/tool.py +731 -0
- crabcode_gateway/__init__.py +18 -0
- crabcode_gateway/acp/__init__.py +13 -0
- crabcode_gateway/acp/agent.py +1581 -0
- crabcode_gateway/acp/session.py +114 -0
- crabcode_gateway/acp/transport.py +39 -0
- crabcode_gateway/acp/types.py +90 -0
- crabcode_gateway/adapter.py +30 -0
- crabcode_gateway/auth.py +139 -0
- crabcode_gateway/computer_use.py +364 -0
- crabcode_gateway/document_engine.py +637 -0
- crabcode_gateway/document_worker.py +301 -0
- crabcode_gateway/event_bus.py +655 -0
- crabcode_gateway/grpc/__init__.py +0 -0
- crabcode_gateway/grpc/crabcode_pb2.py +70 -0
- crabcode_gateway/grpc/crabcode_pb2_grpc.py +474 -0
- crabcode_gateway/grpc/proto/crabcode.proto +160 -0
- crabcode_gateway/grpc/server.py +765 -0
- crabcode_gateway/middleware.py +344 -0
- crabcode_gateway/protocol.py +17 -0
- crabcode_gateway/routes/__init__.py +0 -0
- crabcode_gateway/routes/agent.py +392 -0
- crabcode_gateway/routes/auth.py +150 -0
- crabcode_gateway/routes/computer_use.py +101 -0
- crabcode_gateway/routes/config.py +1698 -0
- crabcode_gateway/routes/document.py +1810 -0
- crabcode_gateway/routes/event.py +3801 -0
- crabcode_gateway/routes/git_review.py +232 -0
- crabcode_gateway/routes/health.py +14 -0
- crabcode_gateway/routes/peer.py +78 -0
- crabcode_gateway/routes/permission.py +121 -0
- crabcode_gateway/routes/schedule.py +300 -0
- crabcode_gateway/routes/session.py +1496 -0
- crabcode_gateway/routes/snapshot.py +211 -0
- crabcode_gateway/routes/tasks.py +318 -0
- crabcode_gateway/routes/team.py +392 -0
- crabcode_gateway/routes/usage.py +49 -0
- crabcode_gateway/routes/workspace.py +299 -0
- crabcode_gateway/safe_file.py +63 -0
- crabcode_gateway/schemas.py +1820 -0
- crabcode_gateway/server.py +514 -0
- crabcode_gateway/session_registry.py +36 -0
- crabcode_gateway/task_registry.py +708 -0
- crabcode_gateway/turn_details.py +60 -0
- crabcode_search/__init__.py +11 -0
- crabcode_search/background.py +368 -0
- crabcode_search/chunker.py +517 -0
- crabcode_search/embedder.py +497 -0
- crabcode_search/indexer.py +215 -0
- crabcode_search/store.py +222 -0
- 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())
|