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.
Files changed (114) hide show
  1. seedcode/__init__.py +14 -0
  2. seedcode/__main__.py +12 -0
  3. seedcode/app.py +508 -0
  4. seedcode/apps/__init__.py +32 -0
  5. seedcode/apps/discovery.py +241 -0
  6. seedcode/apps/installer.py +164 -0
  7. seedcode/apps/launcher.py +156 -0
  8. seedcode/apps/verifier.py +119 -0
  9. seedcode/assets/logo.txt +15 -0
  10. seedcode/cli.py +95 -0
  11. seedcode/commands/__init__.py +81 -0
  12. seedcode/commands/about.py +34 -0
  13. seedcode/commands/agent.py +94 -0
  14. seedcode/commands/assist.py +201 -0
  15. seedcode/commands/clear.py +20 -0
  16. seedcode/commands/desktop.py +104 -0
  17. seedcode/commands/doctor.py +152 -0
  18. seedcode/commands/help.py +61 -0
  19. seedcode/commands/history.py +365 -0
  20. seedcode/commands/palette.py +100 -0
  21. seedcode/commands/provider.py +451 -0
  22. seedcode/commands/theme.py +76 -0
  23. seedcode/computer/__init__.py +98 -0
  24. seedcode/computer/browser.py +276 -0
  25. seedcode/computer/browser_cdp.py +567 -0
  26. seedcode/computer/browser_engine.py +546 -0
  27. seedcode/computer/browser_extract.py +301 -0
  28. seedcode/computer/browser_popups.py +329 -0
  29. seedcode/computer/browser_selenium.py +209 -0
  30. seedcode/computer/browser_skills.py +245 -0
  31. seedcode/computer/catalog.py +200 -0
  32. seedcode/computer/controller.py +324 -0
  33. seedcode/computer/dispatcher.py +272 -0
  34. seedcode/computer/dpi.py +185 -0
  35. seedcode/computer/engine.py +105 -0
  36. seedcode/computer/keyboard.py +101 -0
  37. seedcode/computer/logbook.py +104 -0
  38. seedcode/computer/mouse.py +48 -0
  39. seedcode/computer/ocr.py +213 -0
  40. seedcode/computer/operator_skills.py +577 -0
  41. seedcode/computer/permissions.py +203 -0
  42. seedcode/computer/recovery.py +115 -0
  43. seedcode/computer/registry.py +107 -0
  44. seedcode/computer/resolver.py +434 -0
  45. seedcode/computer/screen.py +130 -0
  46. seedcode/computer/screen_state.py +412 -0
  47. seedcode/computer/selfguard.py +197 -0
  48. seedcode/computer/semantic.py +100 -0
  49. seedcode/computer/skills.py +139 -0
  50. seedcode/computer/state.py +199 -0
  51. seedcode/computer/verifier.py +177 -0
  52. seedcode/computer/vision.py +327 -0
  53. seedcode/computer/windows.py +217 -0
  54. seedcode/config/__init__.py +8 -0
  55. seedcode/config/defaults.py +22 -0
  56. seedcode/config/manager.py +62 -0
  57. seedcode/core/__init__.py +31 -0
  58. seedcode/core/agent.py +534 -0
  59. seedcode/core/chat.py +128 -0
  60. seedcode/core/client.py +9 -0
  61. seedcode/core/errors.py +199 -0
  62. seedcode/core/identity.py +66 -0
  63. seedcode/core/identity_store.py +119 -0
  64. seedcode/core/lifecycle.py +240 -0
  65. seedcode/core/limits.py +35 -0
  66. seedcode/core/models.py +347 -0
  67. seedcode/core/project.py +96 -0
  68. seedcode/core/providers/__init__.py +58 -0
  69. seedcode/core/providers/aerolink.py +324 -0
  70. seedcode/core/providers/base.py +230 -0
  71. seedcode/core/providers/freemodel.py +931 -0
  72. seedcode/core/providers/ollama.py +262 -0
  73. seedcode/core/providers/openrouter.py +393 -0
  74. seedcode/core/streaming.py +21 -0
  75. seedcode/memory/__init__.py +8 -0
  76. seedcode/memory/manager.py +47 -0
  77. seedcode/memory/storage.py +38 -0
  78. seedcode/memory/store.py +257 -0
  79. seedcode/tools/__init__.py +35 -0
  80. seedcode/tools/base.py +179 -0
  81. seedcode/tools/desktop.py +371 -0
  82. seedcode/tools/filesystem.py +309 -0
  83. seedcode/tools/git.py +72 -0
  84. seedcode/tools/patch.py +170 -0
  85. seedcode/tools/permissions.py +288 -0
  86. seedcode/tools/search.py +137 -0
  87. seedcode/tools/terminal.py +200 -0
  88. seedcode/tools/textio.py +59 -0
  89. seedcode/ui/__init__.py +164 -0
  90. seedcode/ui/badges.py +64 -0
  91. seedcode/ui/banner.py +78 -0
  92. seedcode/ui/dashboard.py +197 -0
  93. seedcode/ui/dialog.py +62 -0
  94. seedcode/ui/fuzzy.py +128 -0
  95. seedcode/ui/layout.py +54 -0
  96. seedcode/ui/menu.py +61 -0
  97. seedcode/ui/palette.py +40 -0
  98. seedcode/ui/progress.py +41 -0
  99. seedcode/ui/prompts.py +16 -0
  100. seedcode/ui/renderer.py +36 -0
  101. seedcode/ui/searchbox.py +70 -0
  102. seedcode/ui/selector.py +514 -0
  103. seedcode/ui/statusbar.py +38 -0
  104. seedcode/ui/textbox.py +61 -0
  105. seedcode/ui/theme.py +204 -0
  106. seedcode/ui/tree.py +91 -0
  107. seedcode/utils/__init__.py +22 -0
  108. seedcode/utils/helpers.py +97 -0
  109. seedcode/utils/logger.py +65 -0
  110. seedcode_cli-6.1.5.dist-info/METADATA +368 -0
  111. seedcode_cli-6.1.5.dist-info/RECORD +114 -0
  112. seedcode_cli-6.1.5.dist-info/WHEEL +4 -0
  113. seedcode_cli-6.1.5.dist-info/entry_points.txt +2 -0
  114. 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