seedcode-cli 6.1.5__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- seedcode/__init__.py +14 -0
- seedcode/__main__.py +12 -0
- seedcode/app.py +508 -0
- seedcode/apps/__init__.py +32 -0
- seedcode/apps/discovery.py +241 -0
- seedcode/apps/installer.py +164 -0
- seedcode/apps/launcher.py +156 -0
- seedcode/apps/verifier.py +119 -0
- seedcode/assets/logo.txt +15 -0
- seedcode/cli.py +95 -0
- seedcode/commands/__init__.py +81 -0
- seedcode/commands/about.py +34 -0
- seedcode/commands/agent.py +94 -0
- seedcode/commands/assist.py +201 -0
- seedcode/commands/clear.py +20 -0
- seedcode/commands/desktop.py +104 -0
- seedcode/commands/doctor.py +152 -0
- seedcode/commands/help.py +61 -0
- seedcode/commands/history.py +365 -0
- seedcode/commands/palette.py +100 -0
- seedcode/commands/provider.py +451 -0
- seedcode/commands/theme.py +76 -0
- seedcode/computer/__init__.py +98 -0
- seedcode/computer/browser.py +276 -0
- seedcode/computer/browser_cdp.py +567 -0
- seedcode/computer/browser_engine.py +546 -0
- seedcode/computer/browser_extract.py +301 -0
- seedcode/computer/browser_popups.py +329 -0
- seedcode/computer/browser_selenium.py +209 -0
- seedcode/computer/browser_skills.py +245 -0
- seedcode/computer/catalog.py +200 -0
- seedcode/computer/controller.py +324 -0
- seedcode/computer/dispatcher.py +272 -0
- seedcode/computer/dpi.py +185 -0
- seedcode/computer/engine.py +105 -0
- seedcode/computer/keyboard.py +101 -0
- seedcode/computer/logbook.py +104 -0
- seedcode/computer/mouse.py +48 -0
- seedcode/computer/ocr.py +213 -0
- seedcode/computer/operator_skills.py +577 -0
- seedcode/computer/permissions.py +203 -0
- seedcode/computer/recovery.py +115 -0
- seedcode/computer/registry.py +107 -0
- seedcode/computer/resolver.py +434 -0
- seedcode/computer/screen.py +130 -0
- seedcode/computer/screen_state.py +412 -0
- seedcode/computer/selfguard.py +197 -0
- seedcode/computer/semantic.py +100 -0
- seedcode/computer/skills.py +139 -0
- seedcode/computer/state.py +199 -0
- seedcode/computer/verifier.py +177 -0
- seedcode/computer/vision.py +327 -0
- seedcode/computer/windows.py +217 -0
- seedcode/config/__init__.py +8 -0
- seedcode/config/defaults.py +22 -0
- seedcode/config/manager.py +62 -0
- seedcode/core/__init__.py +31 -0
- seedcode/core/agent.py +534 -0
- seedcode/core/chat.py +128 -0
- seedcode/core/client.py +9 -0
- seedcode/core/errors.py +199 -0
- seedcode/core/identity.py +66 -0
- seedcode/core/identity_store.py +119 -0
- seedcode/core/lifecycle.py +240 -0
- seedcode/core/limits.py +35 -0
- seedcode/core/models.py +347 -0
- seedcode/core/project.py +96 -0
- seedcode/core/providers/__init__.py +58 -0
- seedcode/core/providers/aerolink.py +324 -0
- seedcode/core/providers/base.py +230 -0
- seedcode/core/providers/freemodel.py +931 -0
- seedcode/core/providers/ollama.py +262 -0
- seedcode/core/providers/openrouter.py +393 -0
- seedcode/core/streaming.py +21 -0
- seedcode/memory/__init__.py +8 -0
- seedcode/memory/manager.py +47 -0
- seedcode/memory/storage.py +38 -0
- seedcode/memory/store.py +257 -0
- seedcode/tools/__init__.py +35 -0
- seedcode/tools/base.py +179 -0
- seedcode/tools/desktop.py +371 -0
- seedcode/tools/filesystem.py +309 -0
- seedcode/tools/git.py +72 -0
- seedcode/tools/patch.py +170 -0
- seedcode/tools/permissions.py +288 -0
- seedcode/tools/search.py +137 -0
- seedcode/tools/terminal.py +200 -0
- seedcode/tools/textio.py +59 -0
- seedcode/ui/__init__.py +164 -0
- seedcode/ui/badges.py +64 -0
- seedcode/ui/banner.py +78 -0
- seedcode/ui/dashboard.py +197 -0
- seedcode/ui/dialog.py +62 -0
- seedcode/ui/fuzzy.py +128 -0
- seedcode/ui/layout.py +54 -0
- seedcode/ui/menu.py +61 -0
- seedcode/ui/palette.py +40 -0
- seedcode/ui/progress.py +41 -0
- seedcode/ui/prompts.py +16 -0
- seedcode/ui/renderer.py +36 -0
- seedcode/ui/searchbox.py +70 -0
- seedcode/ui/selector.py +514 -0
- seedcode/ui/statusbar.py +38 -0
- seedcode/ui/textbox.py +61 -0
- seedcode/ui/theme.py +204 -0
- seedcode/ui/tree.py +91 -0
- seedcode/utils/__init__.py +22 -0
- seedcode/utils/helpers.py +97 -0
- seedcode/utils/logger.py +65 -0
- seedcode_cli-6.1.5.dist-info/METADATA +368 -0
- seedcode_cli-6.1.5.dist-info/RECORD +114 -0
- seedcode_cli-6.1.5.dist-info/WHEEL +4 -0
- seedcode_cli-6.1.5.dist-info/entry_points.txt +2 -0
- seedcode_cli-6.1.5.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,451 @@
|
|
|
1
|
+
"""Provider and model selection: /provider, /model.
|
|
2
|
+
|
|
3
|
+
Both commands are fully interactive: arrow keys move, typing fuzzy-filters
|
|
4
|
+
the live list, Enter confirms, Esc cancels. The provider selector shows
|
|
5
|
+
status badges, the backend, and each provider's current model; the model
|
|
6
|
+
selector groups the catalogue by family. The same flows are reused by
|
|
7
|
+
first-run onboarding (:mod:`seedcode.app`), so setup and mid-session
|
|
8
|
+
switching behave identically.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
from ..config import save_config
|
|
14
|
+
from ..core.providers import (
|
|
15
|
+
PROVIDERS,
|
|
16
|
+
ModelInfo,
|
|
17
|
+
Provider,
|
|
18
|
+
ProviderError,
|
|
19
|
+
get_provider,
|
|
20
|
+
)
|
|
21
|
+
from ..core.providers.base import STATUS_CONNECTED, STATUS_OFFLINE
|
|
22
|
+
from ..core.providers.freemodel import AUTO_MODEL
|
|
23
|
+
from ..ui.badges import badge_for_status
|
|
24
|
+
from ..ui.menu import MenuItem, run_menu
|
|
25
|
+
from ..ui.selector import Option, select
|
|
26
|
+
from ..ui.textbox import read_text
|
|
27
|
+
from . import CommandContext, CommandResult, command
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
# --- provider selection ------------------------------------------------------
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def _resolve_provider(text: str) -> Provider | None:
|
|
34
|
+
"""Match user input against provider ids and labels (prefix-tolerant)."""
|
|
35
|
+
t = text.strip().lower()
|
|
36
|
+
if not t:
|
|
37
|
+
return None
|
|
38
|
+
for p in PROVIDERS.values():
|
|
39
|
+
if t in (p.id, p.label.lower()):
|
|
40
|
+
return p
|
|
41
|
+
matches = [
|
|
42
|
+
p
|
|
43
|
+
for p in PROVIDERS.values()
|
|
44
|
+
if p.id.startswith(t) or p.label.lower().startswith(t)
|
|
45
|
+
]
|
|
46
|
+
return matches[0] if len(matches) == 1 else None
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def _provider_backend(provider: Provider, config) -> str:
|
|
50
|
+
if provider.id == "ollama":
|
|
51
|
+
return "Local"
|
|
52
|
+
return provider.backend_label or f"{provider.label} API"
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def _provider_menu(ui, config) -> Provider | None:
|
|
56
|
+
"""Interactive provider selector: badge, backend, and current model."""
|
|
57
|
+
options = []
|
|
58
|
+
for p in PROVIDERS.values():
|
|
59
|
+
entry = config.providers.get(p.id)
|
|
60
|
+
model = entry.model if entry and entry.model else "—"
|
|
61
|
+
if model == AUTO_MODEL:
|
|
62
|
+
model = "Auto"
|
|
63
|
+
options.append(
|
|
64
|
+
Option(
|
|
65
|
+
p.label,
|
|
66
|
+
value=p.id,
|
|
67
|
+
badge=badge_for_status(p.status),
|
|
68
|
+
columns=(_provider_backend(p, config), model),
|
|
69
|
+
)
|
|
70
|
+
)
|
|
71
|
+
chosen = select(
|
|
72
|
+
options,
|
|
73
|
+
title="Provider",
|
|
74
|
+
hint="↑↓ move type to filter Enter select Esc cancel",
|
|
75
|
+
initial=config.provider,
|
|
76
|
+
)
|
|
77
|
+
if chosen is None:
|
|
78
|
+
ui.dim("Cancelled.")
|
|
79
|
+
return None
|
|
80
|
+
return PROVIDERS[str(chosen)]
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def _collect_key(ui, config, provider: Provider, *, replacing: bool = False) -> bool:
|
|
84
|
+
"""Prompt for, validate, and save an API key for ``provider``.
|
|
85
|
+
|
|
86
|
+
Returns False when the user cancels. Only this provider's entry is
|
|
87
|
+
written — other providers' keys are never touched.
|
|
88
|
+
"""
|
|
89
|
+
provider.prepare(config) # bind validation to the configured sub-backend
|
|
90
|
+
if replacing:
|
|
91
|
+
ui.info(f"Enter a new API key for {provider.label}.")
|
|
92
|
+
ui.dim(f"Current: {config.masked_key(provider.id)}")
|
|
93
|
+
else:
|
|
94
|
+
ui.info(f"{provider.label} needs an API key.")
|
|
95
|
+
if provider.key_hint:
|
|
96
|
+
ui.dim(f"Key: {provider.key_hint}")
|
|
97
|
+
while True:
|
|
98
|
+
key = read_text("API Key > ", password=True)
|
|
99
|
+
if key is None or not key:
|
|
100
|
+
ui.dim("Cancelled — no key saved.")
|
|
101
|
+
return False
|
|
102
|
+
with ui.thinking("Validating key"):
|
|
103
|
+
result = provider.validate_key(key)
|
|
104
|
+
if result.ok:
|
|
105
|
+
# Only a key that passed real authentication is ever saved.
|
|
106
|
+
config.set_api_key(provider.id, key)
|
|
107
|
+
save_config(config)
|
|
108
|
+
provider.status = STATUS_CONNECTED
|
|
109
|
+
ui.success(result.message)
|
|
110
|
+
return True
|
|
111
|
+
ui.error(result.message)
|
|
112
|
+
ui.dim("Try again, or press Esc to cancel.")
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def _ensure_ready(ui, config, provider: Provider) -> bool:
|
|
116
|
+
"""Make ``provider`` usable: collect+validate a key, or detect Ollama.
|
|
117
|
+
|
|
118
|
+
Returns False only when the user cancels key entry; a stopped Ollama
|
|
119
|
+
server is reported but not fatal (the user may start it later).
|
|
120
|
+
"""
|
|
121
|
+
if not provider.requires_key:
|
|
122
|
+
with ui.thinking("Checking Ollama"):
|
|
123
|
+
running = provider.detect(config)
|
|
124
|
+
provider.status = STATUS_CONNECTED if running else STATUS_OFFLINE
|
|
125
|
+
if running:
|
|
126
|
+
ui.success("Ollama server detected.")
|
|
127
|
+
else:
|
|
128
|
+
ui.warning(
|
|
129
|
+
f"Ollama is not reachable at {config.ollama_host}. "
|
|
130
|
+
"Start it with 'ollama serve' — chatting will fail until it runs."
|
|
131
|
+
)
|
|
132
|
+
return True
|
|
133
|
+
|
|
134
|
+
if config.get_api_key(provider.id).strip():
|
|
135
|
+
# Existing key: refresh this provider's connection status with a
|
|
136
|
+
# real request so the selector badge reflects reality immediately.
|
|
137
|
+
with ui.thinking(f"Checking {provider.label}"):
|
|
138
|
+
provider.refresh_status(config)
|
|
139
|
+
return True
|
|
140
|
+
return _collect_key(ui, config, provider)
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def select_provider(ui, config, target: str = "") -> bool:
|
|
144
|
+
"""Switch the active provider; returns True when the switch completed.
|
|
145
|
+
|
|
146
|
+
Only ``active_provider`` changes — every provider keeps its own saved
|
|
147
|
+
API key and model, so switching back restores them untouched.
|
|
148
|
+
"""
|
|
149
|
+
chosen: Provider | None = None
|
|
150
|
+
if target:
|
|
151
|
+
chosen = _resolve_provider(target)
|
|
152
|
+
if chosen is None:
|
|
153
|
+
ui.warning(f"Unknown provider '{target}'.")
|
|
154
|
+
if chosen is None:
|
|
155
|
+
chosen = _provider_menu(ui, config)
|
|
156
|
+
if chosen is None:
|
|
157
|
+
return False
|
|
158
|
+
|
|
159
|
+
previous = config.provider
|
|
160
|
+
config.provider = chosen.id
|
|
161
|
+
if not _ensure_ready(ui, config, chosen):
|
|
162
|
+
config.provider = previous # cancelled key entry: keep the old backend
|
|
163
|
+
return False
|
|
164
|
+
|
|
165
|
+
save_config(config)
|
|
166
|
+
ui.success(f"Provider set to {chosen.label}.")
|
|
167
|
+
# The provider's own saved model is active again automatically.
|
|
168
|
+
if config.model:
|
|
169
|
+
ui.dim(f"Model: {config.model}")
|
|
170
|
+
else:
|
|
171
|
+
ui.warning(f"No model selected for {chosen.label} yet — run /model.")
|
|
172
|
+
return True
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
# --- model selection ---------------------------------------------------------
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
def _set_model(ui, config, model_id: str) -> None:
|
|
179
|
+
# Written into the ACTIVE provider's own slot — other providers keep theirs.
|
|
180
|
+
config.model = model_id
|
|
181
|
+
save_config(config)
|
|
182
|
+
ui.success(f"Model set to {model_id}")
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
def _match_model(models: list[ModelInfo], text: str) -> ModelInfo | None:
|
|
186
|
+
"""Exact id match first, then a unique case-insensitive substring."""
|
|
187
|
+
t = text.strip().lower()
|
|
188
|
+
for m in models:
|
|
189
|
+
if m.id.lower() == t:
|
|
190
|
+
return m
|
|
191
|
+
partial = [m for m in models if t in m.id.lower() or t in m.label.lower()]
|
|
192
|
+
return partial[0] if len(partial) == 1 else None
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
# Family keywords for grouping the model selector (checked in order).
|
|
196
|
+
_FAMILIES: tuple[tuple[str, str], ...] = (
|
|
197
|
+
("codex", "Codex"),
|
|
198
|
+
("claude", "Claude"),
|
|
199
|
+
("gpt", "GPT"),
|
|
200
|
+
("o1", "GPT"),
|
|
201
|
+
("o3", "GPT"),
|
|
202
|
+
("qwen", "Qwen"),
|
|
203
|
+
("deepseek", "DeepSeek"),
|
|
204
|
+
("gemini", "Gemini"),
|
|
205
|
+
("gemma", "Gemini"),
|
|
206
|
+
("llama", "Llama"),
|
|
207
|
+
("mistral", "Mistral"),
|
|
208
|
+
("mixtral", "Mistral"),
|
|
209
|
+
)
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
def _model_group(model: ModelInfo) -> str:
|
|
213
|
+
"""Family header for the grouped model selector."""
|
|
214
|
+
hay = f"{model.id} {model.label}".lower()
|
|
215
|
+
for needle, family in _FAMILIES:
|
|
216
|
+
if needle in hay:
|
|
217
|
+
return family
|
|
218
|
+
if "/" in model.id:
|
|
219
|
+
vendor = model.id.split("/", 1)[0]
|
|
220
|
+
return vendor.replace("-", " ").title()
|
|
221
|
+
return "Other"
|
|
222
|
+
|
|
223
|
+
|
|
224
|
+
def _model_options(models: list[ModelInfo], current: str) -> list[Option]:
|
|
225
|
+
"""Grouped, badge-carrying options for the model selector."""
|
|
226
|
+
grouped: dict[str, list[ModelInfo]] = {}
|
|
227
|
+
for m in models:
|
|
228
|
+
grouped.setdefault(_model_group(m), []).append(m)
|
|
229
|
+
options: list[Option] = []
|
|
230
|
+
for family in sorted(grouped, key=lambda g: (g == "Other", g.lower())):
|
|
231
|
+
for m in grouped[family]:
|
|
232
|
+
detail = m.detail
|
|
233
|
+
if m.label and m.label != m.id:
|
|
234
|
+
detail = f"{m.label} {m.detail}".strip()
|
|
235
|
+
options.append(
|
|
236
|
+
Option(
|
|
237
|
+
m.id,
|
|
238
|
+
value=m.id,
|
|
239
|
+
detail=detail,
|
|
240
|
+
group=family,
|
|
241
|
+
badge="ready" if m.id == current else "",
|
|
242
|
+
)
|
|
243
|
+
)
|
|
244
|
+
return options
|
|
245
|
+
|
|
246
|
+
|
|
247
|
+
def _pick_model_interactive(ui, config, provider: Provider, models: list[ModelInfo]) -> None:
|
|
248
|
+
"""The interactive grouped model selector (plus Auto and OpenRouter modes)."""
|
|
249
|
+
options: list[Option] = []
|
|
250
|
+
if provider.supports_auto:
|
|
251
|
+
options.append(
|
|
252
|
+
Option(
|
|
253
|
+
"Auto",
|
|
254
|
+
value=AUTO_MODEL,
|
|
255
|
+
detail="best free model picked per request",
|
|
256
|
+
group="Modes",
|
|
257
|
+
badge="ready" if config.model == AUTO_MODEL else "",
|
|
258
|
+
)
|
|
259
|
+
)
|
|
260
|
+
if provider.id == "openrouter":
|
|
261
|
+
mode = provider.extra_settings(config).get("mode", "free")
|
|
262
|
+
other = "pro" if mode == "free" else "free"
|
|
263
|
+
options.append(
|
|
264
|
+
Option(
|
|
265
|
+
f"Switch to {other.title()} models",
|
|
266
|
+
value=f"__mode__{other}",
|
|
267
|
+
detail=f"currently showing {mode} models",
|
|
268
|
+
group="Modes",
|
|
269
|
+
)
|
|
270
|
+
)
|
|
271
|
+
options.extend(_model_options(models, config.model))
|
|
272
|
+
|
|
273
|
+
chosen = select(
|
|
274
|
+
options,
|
|
275
|
+
title=f"Model — {provider.label} ({len(models)} available)",
|
|
276
|
+
hint="type to filter (fuzzy) ↑↓ move Enter select Esc cancel",
|
|
277
|
+
initial=config.model or None,
|
|
278
|
+
max_rows=14,
|
|
279
|
+
)
|
|
280
|
+
if chosen is None:
|
|
281
|
+
ui.dim("Cancelled.")
|
|
282
|
+
return
|
|
283
|
+
choice = str(chosen)
|
|
284
|
+
if choice.startswith("__mode__"):
|
|
285
|
+
ok, message = provider.set_extra_setting(config, "mode", choice[len("__mode__"):])
|
|
286
|
+
if not ok:
|
|
287
|
+
ui.warning(message)
|
|
288
|
+
return
|
|
289
|
+
save_config(config)
|
|
290
|
+
ui.success(message)
|
|
291
|
+
try:
|
|
292
|
+
with ui.thinking("Fetching models"):
|
|
293
|
+
refreshed = provider.list_models(config)
|
|
294
|
+
except ProviderError as exc:
|
|
295
|
+
ui.error(str(exc))
|
|
296
|
+
return
|
|
297
|
+
_pick_model_interactive(ui, config, provider, refreshed)
|
|
298
|
+
return
|
|
299
|
+
if choice == AUTO_MODEL:
|
|
300
|
+
_set_model(ui, config, AUTO_MODEL)
|
|
301
|
+
ui.dim("(Auto mode: the best free model is picked per request)")
|
|
302
|
+
return
|
|
303
|
+
_set_model(ui, config, choice)
|
|
304
|
+
|
|
305
|
+
|
|
306
|
+
def select_model(ui, config, target: str = "") -> None:
|
|
307
|
+
"""Browse the live model catalogue of the active provider and pick one.
|
|
308
|
+
|
|
309
|
+
Providers with ``supports_auto`` additionally offer Auto mode: the best
|
|
310
|
+
model is resolved from the live catalogue on every request.
|
|
311
|
+
"""
|
|
312
|
+
try:
|
|
313
|
+
provider = get_provider(config.provider)
|
|
314
|
+
except ProviderError as exc:
|
|
315
|
+
ui.error(str(exc))
|
|
316
|
+
return
|
|
317
|
+
if provider.requires_key and not config.get_api_key(provider.id).strip():
|
|
318
|
+
ui.warning(f"{provider.label} has no API key yet — run /provider first.")
|
|
319
|
+
return
|
|
320
|
+
|
|
321
|
+
if target and provider.supports_auto and target.lower() in ("auto", "a"):
|
|
322
|
+
_set_model(ui, config, AUTO_MODEL)
|
|
323
|
+
ui.dim("(Auto mode: the best free model is picked per request)")
|
|
324
|
+
return
|
|
325
|
+
|
|
326
|
+
try:
|
|
327
|
+
with ui.thinking("Fetching models"):
|
|
328
|
+
models = provider.list_models(config)
|
|
329
|
+
except ProviderError as exc:
|
|
330
|
+
if target and provider.id == "aerolink":
|
|
331
|
+
# AeroLink may not expose /v1/models; accept the typed id as-is.
|
|
332
|
+
_set_model(ui, config, target)
|
|
333
|
+
ui.dim("(model list unavailable — id saved without verification)")
|
|
334
|
+
else:
|
|
335
|
+
ui.error(str(exc))
|
|
336
|
+
return
|
|
337
|
+
|
|
338
|
+
if target:
|
|
339
|
+
m = _match_model(models, target)
|
|
340
|
+
if m is not None:
|
|
341
|
+
_set_model(ui, config, m.id)
|
|
342
|
+
else:
|
|
343
|
+
ui.warning(f"No model matching '{target}'. Run /model to browse.")
|
|
344
|
+
return
|
|
345
|
+
|
|
346
|
+
_pick_model_interactive(ui, config, provider, models)
|
|
347
|
+
|
|
348
|
+
|
|
349
|
+
# --- command handlers --------------------------------------------------------
|
|
350
|
+
|
|
351
|
+
|
|
352
|
+
@command(
|
|
353
|
+
"provider",
|
|
354
|
+
"Select the active provider "
|
|
355
|
+
"(OpenRouter, FreeModel Claude, FreeModel Codex, AeroLink, Ollama)",
|
|
356
|
+
)
|
|
357
|
+
def _provider_cmd(ctx: CommandContext, arg: str) -> CommandResult:
|
|
358
|
+
select_provider(ctx.ui, ctx.config, arg.strip())
|
|
359
|
+
return CommandResult()
|
|
360
|
+
|
|
361
|
+
|
|
362
|
+
@command("model", "Browse and select a model for the active provider", aliases=("show",))
|
|
363
|
+
def _model_cmd(ctx: CommandContext, arg: str) -> CommandResult:
|
|
364
|
+
target = arg.strip()
|
|
365
|
+
# Support the documented "/show model" phrasing.
|
|
366
|
+
if target.lower().startswith("model"):
|
|
367
|
+
target = target[len("model"):].strip()
|
|
368
|
+
select_model(ctx.ui, ctx.config, target)
|
|
369
|
+
return CommandResult()
|
|
370
|
+
|
|
371
|
+
|
|
372
|
+
def apikey_menu(ui, config) -> None:
|
|
373
|
+
"""Manage the ACTIVE provider's API key: view, replace, remove, validate."""
|
|
374
|
+
try:
|
|
375
|
+
provider = get_provider(config.provider)
|
|
376
|
+
except ProviderError as exc:
|
|
377
|
+
ui.error(str(exc))
|
|
378
|
+
return
|
|
379
|
+
if not provider.requires_key:
|
|
380
|
+
ui.info(f"{provider.label} does not use an API key.")
|
|
381
|
+
return
|
|
382
|
+
|
|
383
|
+
while True:
|
|
384
|
+
has_key = bool(config.get_api_key(provider.id).strip())
|
|
385
|
+
choice = run_menu(
|
|
386
|
+
[
|
|
387
|
+
MenuItem("View", "view", status=config.masked_key(provider.id)),
|
|
388
|
+
MenuItem("Replace", "replace"),
|
|
389
|
+
MenuItem("Remove", "remove", disabled=not has_key),
|
|
390
|
+
MenuItem("Validate", "validate", disabled=not has_key),
|
|
391
|
+
],
|
|
392
|
+
title=f"API Key — {provider.label}",
|
|
393
|
+
hint="↑↓ move Enter select Esc back",
|
|
394
|
+
)
|
|
395
|
+
if choice is None:
|
|
396
|
+
return
|
|
397
|
+
if choice == "view":
|
|
398
|
+
if has_key:
|
|
399
|
+
ui.info(f"{provider.label} key: {config.masked_key(provider.id)}")
|
|
400
|
+
else:
|
|
401
|
+
ui.dim("No key saved yet.")
|
|
402
|
+
elif choice == "replace":
|
|
403
|
+
_collect_key(ui, config, provider, replacing=has_key)
|
|
404
|
+
elif choice == "remove":
|
|
405
|
+
from ..ui.dialog import confirm_dialog
|
|
406
|
+
|
|
407
|
+
if confirm_dialog(
|
|
408
|
+
"Remove the saved key?", yes_label="Remove", no_label="Keep", danger=True
|
|
409
|
+
):
|
|
410
|
+
config.set_api_key(provider.id, "")
|
|
411
|
+
save_config(config)
|
|
412
|
+
ui.success(f"{provider.label} key removed.")
|
|
413
|
+
else:
|
|
414
|
+
ui.dim("Key kept.")
|
|
415
|
+
elif choice == "validate":
|
|
416
|
+
provider.prepare(config)
|
|
417
|
+
with ui.thinking("Validating key"):
|
|
418
|
+
result = provider.validate_key(config.get_api_key(provider.id))
|
|
419
|
+
if result.ok:
|
|
420
|
+
ui.success(result.message)
|
|
421
|
+
else:
|
|
422
|
+
ui.error(result.message)
|
|
423
|
+
|
|
424
|
+
|
|
425
|
+
@command("apikey", "View, replace, remove, or validate the active provider's key",
|
|
426
|
+
aliases=("key",))
|
|
427
|
+
def _apikey_cmd(ctx: CommandContext, arg: str) -> CommandResult:
|
|
428
|
+
key = arg.strip()
|
|
429
|
+
if key:
|
|
430
|
+
# Key given inline: validate and save it directly.
|
|
431
|
+
try:
|
|
432
|
+
provider = get_provider(ctx.config.provider)
|
|
433
|
+
except ProviderError as exc:
|
|
434
|
+
ctx.ui.error(str(exc))
|
|
435
|
+
return CommandResult()
|
|
436
|
+
if not provider.requires_key:
|
|
437
|
+
ctx.ui.info(f"{provider.label} does not use an API key.")
|
|
438
|
+
return CommandResult()
|
|
439
|
+
provider.prepare(ctx.config)
|
|
440
|
+
with ctx.ui.thinking("Validating key"):
|
|
441
|
+
result = provider.validate_key(key)
|
|
442
|
+
if result.ok:
|
|
443
|
+
ctx.config.set_api_key(provider.id, key)
|
|
444
|
+
save_config(ctx.config)
|
|
445
|
+
ctx.ui.success(result.message)
|
|
446
|
+
else:
|
|
447
|
+
ctx.ui.error(result.message)
|
|
448
|
+
return CommandResult()
|
|
449
|
+
|
|
450
|
+
apikey_menu(ctx.ui, ctx.config)
|
|
451
|
+
return CommandResult()
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
"""/theme — interactive theme picker with live preview.
|
|
2
|
+
|
|
3
|
+
Arrow keys switch themes instantly: the on-highlight hook applies each
|
|
4
|
+
palette to the console as the cursor moves, and a sample block above the
|
|
5
|
+
picker shows the new colours immediately. Esc restores the original theme;
|
|
6
|
+
Enter persists the choice to config.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
from rich.text import Text
|
|
12
|
+
|
|
13
|
+
from ..config import save_config
|
|
14
|
+
from ..ui.selector import Option, select
|
|
15
|
+
from ..ui.theme import PALETTES, active_theme_name
|
|
16
|
+
from . import CommandContext, CommandResult, command
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def _preview(ui) -> None:
|
|
20
|
+
"""Print a small sample block in the (just applied) active theme."""
|
|
21
|
+
ui.blank()
|
|
22
|
+
sample = Text()
|
|
23
|
+
sample.append("Seed Code", style="seed.primary")
|
|
24
|
+
sample.append(" Plant ideas. Grow code.\n", style="seed.accent")
|
|
25
|
+
sample.append("Regular text, ", style="seed.text")
|
|
26
|
+
sample.append("dimmed detail, ", style="seed.dim")
|
|
27
|
+
sample.append("warning, ", style="seed.warning")
|
|
28
|
+
sample.append("error.", style="seed.error")
|
|
29
|
+
ui.panel(sample, title="Preview")
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def pick_theme(ui, config) -> None:
|
|
33
|
+
"""Run the live-preview theme picker and persist the selection."""
|
|
34
|
+
original = active_theme_name()
|
|
35
|
+
|
|
36
|
+
def apply_live(option: Option) -> None:
|
|
37
|
+
ui.apply_theme(str(option.value))
|
|
38
|
+
|
|
39
|
+
options = [
|
|
40
|
+
Option(p.label, p.id, detail=p.description)
|
|
41
|
+
for p in PALETTES.values()
|
|
42
|
+
]
|
|
43
|
+
chosen = select(
|
|
44
|
+
options,
|
|
45
|
+
title="Theme",
|
|
46
|
+
hint="↑↓ preview live Enter apply Esc keep current",
|
|
47
|
+
initial=original,
|
|
48
|
+
on_highlight=apply_live,
|
|
49
|
+
searchable=True,
|
|
50
|
+
)
|
|
51
|
+
if chosen is None:
|
|
52
|
+
ui.apply_theme(original)
|
|
53
|
+
ui.dim("Theme unchanged.")
|
|
54
|
+
return
|
|
55
|
+
ui.apply_theme(str(chosen))
|
|
56
|
+
config.theme = str(chosen)
|
|
57
|
+
save_config(config)
|
|
58
|
+
_preview(ui)
|
|
59
|
+
ui.success(f"Theme set to {PALETTES[str(chosen)].label}.")
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
@command("theme", "Pick a colour theme (live preview)")
|
|
63
|
+
def _theme(ctx: CommandContext, arg: str) -> CommandResult:
|
|
64
|
+
name = arg.strip().lower()
|
|
65
|
+
if name:
|
|
66
|
+
if name not in PALETTES:
|
|
67
|
+
known = ", ".join(PALETTES)
|
|
68
|
+
ctx.ui.warning(f"Unknown theme '{name}'. Available: {known}")
|
|
69
|
+
return CommandResult()
|
|
70
|
+
ctx.ui.apply_theme(name)
|
|
71
|
+
ctx.config.theme = name
|
|
72
|
+
save_config(ctx.config)
|
|
73
|
+
ctx.ui.success(f"Theme set to {PALETTES[name].label}.")
|
|
74
|
+
return CommandResult()
|
|
75
|
+
pick_theme(ctx.ui, ctx.config)
|
|
76
|
+
return CommandResult()
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
"""Computer Engine: safe, permissioned control of the local desktop.
|
|
2
|
+
|
|
3
|
+
The engine gives agent mode "hands and eyes" on the machine: mouse, keyboard,
|
|
4
|
+
screenshots, window management, application launch/close, and the registry.
|
|
5
|
+
Everything is Windows-only in this release and every dependency is optional
|
|
6
|
+
(``pip install seedcode-cli[desktop]``) — the rest of Seed Code must import
|
|
7
|
+
this package safely on any platform, so all heavy imports happen lazily and
|
|
8
|
+
:func:`is_available` is the single gate callers consult first.
|
|
9
|
+
|
|
10
|
+
Layout mirrors the tool engine philosophy: small driver modules
|
|
11
|
+
(:mod:`mouse`, :mod:`keyboard`, :mod:`screen`, :mod:`windows`, :mod:`vision`,
|
|
12
|
+
:mod:`registry`) wrap the libraries, :mod:`permissions` owns the Desktop
|
|
13
|
+
Control grant flow, and :class:`~seedcode.computer.controller.ComputerController`
|
|
14
|
+
is the façade the tools talk to.
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
from __future__ import annotations
|
|
18
|
+
|
|
19
|
+
import importlib.util
|
|
20
|
+
import sys
|
|
21
|
+
|
|
22
|
+
from typing import TYPE_CHECKING, Any
|
|
23
|
+
|
|
24
|
+
from .permissions import (
|
|
25
|
+
DesktopGrant,
|
|
26
|
+
DesktopSession,
|
|
27
|
+
SessionPermissionManager,
|
|
28
|
+
session_permissions,
|
|
29
|
+
)
|
|
30
|
+
|
|
31
|
+
if TYPE_CHECKING:
|
|
32
|
+
from .engine import ComputerEngine
|
|
33
|
+
|
|
34
|
+
__all__ = [
|
|
35
|
+
"DesktopGrant",
|
|
36
|
+
"DesktopSession",
|
|
37
|
+
"SessionPermissionManager",
|
|
38
|
+
"session_permissions",
|
|
39
|
+
"REQUIRED_PACKAGES",
|
|
40
|
+
"is_available",
|
|
41
|
+
"missing_packages",
|
|
42
|
+
"get_engine",
|
|
43
|
+
"reset_engine",
|
|
44
|
+
]
|
|
45
|
+
|
|
46
|
+
# import name -> pip name (what to install when missing).
|
|
47
|
+
REQUIRED_PACKAGES: dict[str, str] = {
|
|
48
|
+
"pyautogui": "pyautogui",
|
|
49
|
+
"mss": "mss",
|
|
50
|
+
"pygetwindow": "pygetwindow",
|
|
51
|
+
"uiautomation": "uiautomation",
|
|
52
|
+
"PIL": "pillow",
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
INSTALL_HINT = "pip install seedcode-cli[desktop]"
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def missing_packages() -> list[str]:
|
|
59
|
+
"""Pip names of desktop dependencies that are not importable."""
|
|
60
|
+
missing = []
|
|
61
|
+
for module_name, pip_name in REQUIRED_PACKAGES.items():
|
|
62
|
+
if importlib.util.find_spec(module_name) is None:
|
|
63
|
+
missing.append(pip_name)
|
|
64
|
+
return missing
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def is_available() -> tuple[bool, str]:
|
|
68
|
+
"""Whether desktop control can run here; (ok, human-readable reason)."""
|
|
69
|
+
if sys.platform != "win32":
|
|
70
|
+
return False, "Desktop control is Windows-only in this release."
|
|
71
|
+
missing = missing_packages()
|
|
72
|
+
if missing:
|
|
73
|
+
return (
|
|
74
|
+
False,
|
|
75
|
+
f"Missing packages: {', '.join(missing)}. Install with: {INSTALL_HINT}",
|
|
76
|
+
)
|
|
77
|
+
return True, "Desktop control is available."
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
# One Computer Engine per session, created lazily so importing this package is
|
|
81
|
+
# free on any platform and the (heavy) drivers only load when first used.
|
|
82
|
+
_ENGINE: "ComputerEngine | None" = None
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def get_engine(permissions: Any, controller: Any = None) -> "ComputerEngine":
|
|
86
|
+
"""Return the session's :class:`ComputerEngine`, creating it on first use."""
|
|
87
|
+
global _ENGINE
|
|
88
|
+
if _ENGINE is None:
|
|
89
|
+
from .engine import ComputerEngine
|
|
90
|
+
|
|
91
|
+
_ENGINE = ComputerEngine(permissions=permissions, controller=controller)
|
|
92
|
+
return _ENGINE
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def reset_engine() -> None:
|
|
96
|
+
"""Drop the cached engine (called when the session/permissions change)."""
|
|
97
|
+
global _ENGINE
|
|
98
|
+
_ENGINE = None
|