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,327 @@
1
+ """Vision driver: the agent's eyes on the desktop.
2
+
3
+ The primary source of truth is the Windows UI Automation tree — real
4
+ elements (buttons, inputs, menus, dialogs) with exact coordinates, described
5
+ as text so EVERY provider can use desktop mode, not just multimodal ones.
6
+ Screenshots complement it for image-capable models, and OCR (pytesseract,
7
+ if the user happens to have it) is a best-effort fallback for windows that
8
+ expose no automation tree (games, some custom-drawn apps).
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ from dataclasses import dataclass
14
+
15
+ # Bound the tree walk: deep UIA trees (browsers) can hold thousands of nodes.
16
+ MAX_ELEMENTS = 150
17
+ MAX_DEPTH = 12
18
+
19
+ # Control types worth reporting to the model (interactive or informative).
20
+ _INTERESTING_TYPES = {
21
+ "ButtonControl": "button",
22
+ "EditControl": "input",
23
+ "ComboBoxControl": "combobox",
24
+ "CheckBoxControl": "checkbox",
25
+ "RadioButtonControl": "radio",
26
+ "HyperlinkControl": "link",
27
+ "MenuItemControl": "menuitem",
28
+ "MenuControl": "menu",
29
+ "TabItemControl": "tab",
30
+ "ListItemControl": "listitem",
31
+ "TextControl": "text",
32
+ "DocumentControl": "document",
33
+ "WindowControl": "dialog",
34
+ "TitleBarControl": "titlebar",
35
+ "ToolBarControl": "toolbar",
36
+ "TreeItemControl": "treeitem",
37
+ "SliderControl": "slider",
38
+ }
39
+
40
+
41
+ @dataclass(slots=True)
42
+ class UIElement:
43
+ """One visible UI element with its clickable center point."""
44
+
45
+ role: str
46
+ name: str
47
+ x: int # center
48
+ y: int # center
49
+ width: int
50
+ height: int
51
+ enabled: bool
52
+ # Secondary UIA properties for the resolver's tier-2 matching. Empty when
53
+ # the element (or the platform) does not expose them.
54
+ automation_id: str = ""
55
+ value: str = ""
56
+ help_text: str = ""
57
+
58
+ def describe(self) -> str:
59
+ state = "" if self.enabled else " (disabled)"
60
+ name = self.name[:80] if self.name else "(unnamed)"
61
+ return f'{self.role} "{name}" at ({self.x}, {self.y}){state}'
62
+
63
+
64
+ def snapshot(window_title: str | None = None) -> tuple[str, list[UIElement]]:
65
+ """UI-tree snapshot of the active (or named) window.
66
+
67
+ Returns (window title, elements). Raises ValueError when the target
68
+ window cannot be found.
69
+ """
70
+ import uiautomation as auto
71
+
72
+ if window_title:
73
+ root = auto.WindowControl(searchDepth=1, SubName=window_title)
74
+ if not root.Exists(maxSearchSeconds=2):
75
+ raise ValueError(f"No window found matching '{window_title}'.")
76
+ else:
77
+ root = auto.GetForegroundControl()
78
+ if root is None:
79
+ raise ValueError("No foreground window to inspect.")
80
+ top = root.GetTopLevelControl()
81
+ if top is not None:
82
+ root = top
83
+
84
+ elements: list[UIElement] = []
85
+ _walk(root, elements, depth=0)
86
+ return (root.Name or "(untitled window)", elements)
87
+
88
+
89
+ def _walk(control, out: list[UIElement], depth: int) -> None:
90
+ if len(out) >= MAX_ELEMENTS or depth > MAX_DEPTH:
91
+ return
92
+ for child in control.GetChildren():
93
+ if len(out) >= MAX_ELEMENTS:
94
+ return
95
+ try:
96
+ type_name = child.ControlTypeName
97
+ role = _INTERESTING_TYPES.get(type_name)
98
+ rect = child.BoundingRectangle
99
+ visible = rect is not None and rect.width() > 0 and rect.height() > 0
100
+ if role is not None and visible:
101
+ name = (child.Name or "").strip()
102
+ # Skip anonymous static text: it adds noise, not targets.
103
+ if not (role == "text" and not name):
104
+ out.append(
105
+ UIElement(
106
+ role=role,
107
+ name=name,
108
+ x=rect.left + rect.width() // 2,
109
+ y=rect.top + rect.height() // 2,
110
+ width=rect.width(),
111
+ height=rect.height(),
112
+ enabled=bool(child.IsEnabled),
113
+ automation_id=_safe_prop(child, "AutomationId"),
114
+ value=_value_of(child),
115
+ help_text=_safe_prop(child, "HelpText"),
116
+ )
117
+ )
118
+ except Exception:
119
+ # A single flaky COM element must not kill the whole snapshot.
120
+ continue
121
+ _walk(child, out, depth + 1)
122
+
123
+
124
+ def _safe_prop(control, attr: str) -> str:
125
+ """Read a UIA string property, tolerating COM hiccups and absence."""
126
+ try:
127
+ val = getattr(control, attr, "")
128
+ return str(val).strip() if val else ""
129
+ except Exception:
130
+ return ""
131
+
132
+
133
+ def _value_of(control) -> str:
134
+ """The ValuePattern text of a control (e.g. an input's contents), if any."""
135
+ try:
136
+ pattern = control.GetValuePattern()
137
+ return str(pattern.Value or "").strip()
138
+ except Exception:
139
+ return ""
140
+
141
+
142
+ def describe_snapshot(title: str, elements: list[UIElement]) -> str:
143
+ """Model-facing text rendering of a snapshot."""
144
+ if not elements:
145
+ return (
146
+ f'Window "{title}" exposes no UI Automation elements '
147
+ "(custom-drawn app?). Use desktop_screenshot and OCR/vision instead."
148
+ )
149
+ lines = [f'Window "{title}" — {len(elements)} elements:']
150
+ lines += [f" - {el.describe()}" for el in elements]
151
+ if len(elements) >= MAX_ELEMENTS:
152
+ lines.append(f" ... (truncated at {MAX_ELEMENTS} elements)")
153
+ return "\n".join(lines)
154
+
155
+
156
+ def element_at(x: int, y: int) -> str:
157
+ """Describe the UI element under a point (used to verify actions)."""
158
+ try:
159
+ import uiautomation as auto
160
+
161
+ control = auto.ControlFromPoint(x, y)
162
+ if control is None:
163
+ return "nothing"
164
+ role = _INTERESTING_TYPES.get(control.ControlTypeName, control.ControlTypeName)
165
+ name = (control.Name or "").strip()[:80]
166
+ return f'{role} "{name or "(unnamed)"}"'
167
+ except Exception:
168
+ return "unknown"
169
+
170
+
171
+ def ocr_available() -> bool:
172
+ """Whether OCR can genuinely run here.
173
+
174
+ Checks the engine, not just the Python wrapper: ``pytesseract`` imports
175
+ fine without ``tesseract.exe``, and reporting OCR as available on that
176
+ basis made every call fail at the point of use. See :mod:`.ocr`.
177
+ """
178
+ from . import ocr
179
+
180
+ return ocr.available()
181
+
182
+
183
+ # --- computer vision / image matching (optional, opencv-backed) --------------
184
+
185
+ def cv_available() -> bool:
186
+ """Whether OpenCV is importable (the CV and image-match ladder tiers)."""
187
+ import importlib.util
188
+
189
+ return importlib.util.find_spec("cv2") is not None
190
+
191
+
192
+ def template_locate(
193
+ image_path, template_path, threshold: float = 0.87
194
+ ) -> tuple[int, int, int, int] | None:
195
+ """Locate a reference image (``template_path``) inside a screenshot.
196
+
197
+ The **image-matching** ladder tier: skills that ship a small reference PNG
198
+ (an icon, a logo, a button with no accessibility name) can point the
199
+ resolver at it. Returns the best match's (left, top, width, height) when the
200
+ normalized-correlation score clears ``threshold``, else None. Uses OpenCV;
201
+ returns None when OpenCV is unavailable so the resolver falls through.
202
+ """
203
+ if not cv_available():
204
+ return None
205
+ try:
206
+ import cv2 # type: ignore
207
+ import numpy as np # type: ignore
208
+
209
+ haystack = cv2.imread(str(image_path), cv2.IMREAD_GRAYSCALE)
210
+ needle = cv2.imread(str(template_path), cv2.IMREAD_GRAYSCALE)
211
+ if haystack is None or needle is None:
212
+ return None
213
+ th, tw = needle.shape[:2]
214
+ if th == 0 or tw == 0 or th > haystack.shape[0] or tw > haystack.shape[1]:
215
+ return None
216
+ result = cv2.matchTemplate(haystack, needle, cv2.TM_CCOEFF_NORMED)
217
+ _min_v, max_v, _min_l, max_l = cv2.minMaxLoc(result)
218
+ if max_v < threshold:
219
+ return None
220
+ left, top = int(max_l[0]), int(max_l[1])
221
+ return (left, top, int(tw), int(th))
222
+ except Exception:
223
+ return None
224
+
225
+
226
+ def refine_region_cv(
227
+ image_path, box: tuple[int, int, int, int]
228
+ ) -> tuple[int, int, int, int]:
229
+ """Snap an OCR text box out to the clickable control that contains it.
230
+
231
+ The **computer-vision** ladder tier. Custom-drawn buttons expose no
232
+ accessibility tree; OCR finds the *label* but its center may sit on text
233
+ rather than the true hit target. This finds the smallest rectangular
234
+ contour enclosing the label's center and returns that box, so the click
235
+ lands on the control body. Falls back to the original box when OpenCV is
236
+ unavailable or no better contour is found.
237
+ """
238
+ if not cv_available():
239
+ return box
240
+ try:
241
+ import cv2 # type: ignore
242
+
243
+ img = cv2.imread(str(image_path))
244
+ if img is None:
245
+ return box
246
+ left, top, width, height = box
247
+ cx, cy = left + width // 2, top + height // 2
248
+ gray = cv2.cvtColor(img, cv2.COLOR_BGR2GRAY)
249
+ edges = cv2.Canny(gray, 50, 150)
250
+ edges = cv2.dilate(edges, None, iterations=1)
251
+ contours, _ = cv2.findContours(edges, cv2.RETR_LIST, cv2.CHAIN_APPROX_SIMPLE)
252
+ best = None
253
+ best_area = None
254
+ for cnt in contours:
255
+ bx, by, bw, bh = cv2.boundingRect(cnt)
256
+ # Must contain the label center and be at least as large as the text.
257
+ if bx <= cx <= bx + bw and by <= cy <= by + bh and bw >= width and bh >= height:
258
+ area = bw * bh
259
+ if best_area is None or area < best_area:
260
+ best, best_area = (bx, by, bw, bh), area
261
+ return best or box
262
+ except Exception:
263
+ return box
264
+
265
+
266
+ def ocr_screenshot(image_path) -> str:
267
+ """Best-effort OCR of a screenshot."""
268
+ if not ocr_available():
269
+ from . import ocr as ocr_module
270
+
271
+ _ok, reason = ocr_module.status()
272
+ return (
273
+ f"OCR is unavailable ({reason}). The UI Automation snapshot "
274
+ "(computer_see) is the primary way to read the screen."
275
+ )
276
+ try:
277
+ import pytesseract
278
+ from PIL import Image
279
+
280
+ with Image.open(image_path) as img:
281
+ text = pytesseract.image_to_string(img)
282
+ return text.strip() or "(no text recognised)"
283
+ except Exception as exc:
284
+ return f"OCR failed: {exc}"
285
+
286
+
287
+ def ocr_locate(image_path, phrase: str) -> tuple[int, int, int, int] | None:
288
+ """Find ``phrase`` on a screenshot; return its (left, top, width, height).
289
+
290
+ Used by the element resolver as the fallback path for windows that expose
291
+ no UI Automation tree. Matches a run of consecutive OCR words whose joined
292
+ text contains the phrase (case-insensitive), and returns the bounding box
293
+ that spans them. Returns None when the phrase is not found or OCR is
294
+ unavailable — the resolver then reports the element as unresolvable.
295
+ """
296
+ if not ocr_available():
297
+ return None
298
+ try:
299
+ import pytesseract
300
+ from PIL import Image
301
+
302
+ want = " ".join((phrase or "").lower().split())
303
+ if not want:
304
+ return None
305
+ with Image.open(image_path) as img:
306
+ data = pytesseract.image_to_data(img, output_type=pytesseract.Output.DICT)
307
+ except Exception:
308
+ return None
309
+
310
+ words = data.get("text", [])
311
+ n = len(words)
312
+ # Slide a window of up to the phrase's word-count over the OCR words and
313
+ # accept the first run whose joined text contains the phrase.
314
+ span = max(1, len(want.split()))
315
+ for i in range(n):
316
+ for length in range(1, span + 1):
317
+ if i + length > n:
318
+ break
319
+ chunk = " ".join(w for w in words[i : i + length] if w).lower().strip()
320
+ if chunk and want in chunk:
321
+ lefts = [data["left"][k] for k in range(i, i + length)]
322
+ tops = [data["top"][k] for k in range(i, i + length)]
323
+ rights = [data["left"][k] + data["width"][k] for k in range(i, i + length)]
324
+ bottoms = [data["top"][k] + data["height"][k] for k in range(i, i + length)]
325
+ left, top = min(lefts), min(tops)
326
+ return (left, top, max(rights) - left, max(bottoms) - top)
327
+ return None
@@ -0,0 +1,217 @@
1
+ """Window management driver: list, focus, open, and close applications.
2
+
3
+ Window enumeration and focus use ``pygetwindow`` (Win32 under the hood).
4
+ Opening applications goes through ``os.startfile``/``start`` semantics so
5
+ anything resolvable by the shell (path, registered app, document) works.
6
+ Closing is graceful first (WM_CLOSE) with a ``taskkill`` fallback by name.
7
+
8
+ **Self-protection:** every lookup skips SeedCode's own console window (see
9
+ :mod:`.selfguard`). Title matching here is substring-based, so without the
10
+ guard a request like "close the browser" could resolve to — and terminate —
11
+ the very terminal SeedCode is running in. Controlling our own window is
12
+ deliberately impossible; closing *SeedCode* is the REPL's /exit decision,
13
+ never a window operation.
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ import subprocess
19
+ import sys
20
+ from dataclasses import dataclass
21
+ from typing import Any
22
+
23
+ from . import selfguard
24
+
25
+
26
+ @dataclass(slots=True)
27
+ class WindowInfo:
28
+ """One top-level window as reported to the model."""
29
+
30
+ title: str
31
+ left: int
32
+ top: int
33
+ width: int
34
+ height: int
35
+ active: bool
36
+ minimized: bool
37
+ # Identity metadata (0 when the platform doesn't expose it): the owning
38
+ # process lets the app controller match windows to launches, and the raw
39
+ # handle lets semantic actions drive windows precisely. Our own console
40
+ # is reported with pid=0/hwnd=0 so no matching logic can ever pick it.
41
+ pid: int = 0
42
+ hwnd: int = 0
43
+
44
+ def describe(self) -> str:
45
+ state = "active" if self.active else ("minimized" if self.minimized else "open")
46
+ return (
47
+ f'"{self.title}" [{state}] at ({self.left}, {self.top}) '
48
+ f"size {self.width}x{self.height}"
49
+ )
50
+
51
+
52
+ def _gw():
53
+ import pygetwindow
54
+
55
+ return pygetwindow
56
+
57
+
58
+ def _window_pid_hwnd(win: Any) -> tuple[int, int]:
59
+ """Owning process id + handle of a pygetwindow window (0, 0 when unknown).
60
+
61
+ ``pygetwindow`` exposes the raw handle as ``_hWnd``; the pid comes from
62
+ Win32. Failures degrade to (0, 0) — matching code must treat that as
63
+ "unknown", never as a match.
64
+ """
65
+ hwnd = getattr(win, "_hWnd", 0) or 0
66
+ pid = 0
67
+ if sys.platform == "win32" and hwnd:
68
+ try:
69
+ import ctypes
70
+
71
+ pid_val = ctypes.wintypes.DWORD()
72
+ ctypes.windll.user32.GetWindowThreadProcessId(hwnd, ctypes.byref(pid_val))
73
+ pid = int(pid_val.value)
74
+ except Exception:
75
+ pid = 0
76
+ return pid, int(hwnd)
77
+
78
+
79
+ def list_windows() -> list[WindowInfo]:
80
+ """All titled top-level windows, active one first."""
81
+ gw = _gw()
82
+ active = gw.getActiveWindow()
83
+ active_handle = getattr(active, "_hWnd", None)
84
+ windows = []
85
+ for win in gw.getAllWindows():
86
+ if not (win.title or "").strip():
87
+ continue
88
+ if selfguard.is_own_window(win):
89
+ continue # never report the terminal running SeedCode
90
+ pid, hwnd = _window_pid_hwnd(win)
91
+ windows.append(
92
+ WindowInfo(
93
+ title=win.title,
94
+ left=int(win.left),
95
+ top=int(win.top),
96
+ width=int(win.width),
97
+ height=int(win.height),
98
+ active=getattr(win, "_hWnd", None) == active_handle,
99
+ minimized=bool(win.isMinimized),
100
+ pid=pid,
101
+ hwnd=hwnd,
102
+ )
103
+ )
104
+ windows.sort(key=lambda w: not w.active)
105
+ return windows
106
+
107
+
108
+ def active_window() -> WindowInfo | None:
109
+ """The focused window, or None when nothing is focused."""
110
+ win = _gw().getActiveWindow()
111
+ if win is None or not (win.title or "").strip():
112
+ return None
113
+ if selfguard.is_own_window(win):
114
+ return None # our console is invisible to desktop logic
115
+ pid, hwnd = _window_pid_hwnd(win)
116
+ return WindowInfo(
117
+ title=win.title,
118
+ left=int(win.left),
119
+ top=int(win.top),
120
+ width=int(win.width),
121
+ height=int(win.height),
122
+ active=True,
123
+ minimized=bool(win.isMinimized),
124
+ pid=pid,
125
+ hwnd=hwnd,
126
+ )
127
+
128
+
129
+ def _find(title_substring: str):
130
+ """First window whose title contains ``title_substring`` (case-insensitive).
131
+
132
+ SeedCode's own console is invisible to this lookup: focusing, closing, or
133
+ otherwise acting on the terminal hosting the agent is always a mistake.
134
+ """
135
+ needle = title_substring.strip().lower()
136
+ if not needle:
137
+ raise ValueError("Window title (or part of it) is required.")
138
+ for win in _gw().getAllWindows():
139
+ if selfguard.is_own_window(win):
140
+ continue # never target the terminal running SeedCode
141
+ if needle in (win.title or "").lower():
142
+ return win
143
+ raise ValueError(f"No window found matching '{title_substring}'.")
144
+
145
+
146
+ def focus_window(title_substring: str) -> WindowInfo | None:
147
+ """Bring a window to the foreground; returns the new active window."""
148
+ win = _find(title_substring)
149
+ if win.isMinimized:
150
+ win.restore()
151
+ win.activate()
152
+ return active_window()
153
+
154
+
155
+ def open_app(target: str) -> str:
156
+ """Launch an application or open a document via the shell.
157
+
158
+ ``target`` is anything the Windows shell can resolve: an exe name on
159
+ PATH, a full path, or a registered app (e.g. "notepad", "calc").
160
+ ShellExecute via ``os.startfile`` first (documents, App Paths), then a
161
+ detached ``start`` as fallback — never waiting on the launched process,
162
+ which would hang until the app exits.
163
+ """
164
+ import os
165
+
166
+ target = target.strip()
167
+ if not target:
168
+ raise ValueError("Application name or path is required.")
169
+ try:
170
+ os.startfile(target) # noqa: S606 - deliberate shell-open semantics
171
+ return f"Launched '{target}'."
172
+ except OSError:
173
+ pass
174
+ # Fallback: 'start' resolves PATH executables and shell aliases. The
175
+ # process is detached (no inherited pipes) so this returns immediately.
176
+ try:
177
+ subprocess.Popen(
178
+ f'start "" "{target}"',
179
+ shell=True,
180
+ stdout=subprocess.DEVNULL,
181
+ stderr=subprocess.DEVNULL,
182
+ stdin=subprocess.DEVNULL,
183
+ )
184
+ except OSError as exc:
185
+ raise RuntimeError(f"Could not open '{target}': {exc}")
186
+ return f"Launched '{target}'."
187
+
188
+
189
+ def close_window(title_substring: str, force: bool = False) -> str:
190
+ """Close a window gracefully (WM_CLOSE); ``force`` kills the process.
191
+
192
+ Refuses outright when the target *is* our own console: closing SeedCode
193
+ is an explicit /exit decision made by the user in the REPL, never a
194
+ side effect of a desktop task. The ``taskkill`` fallback is additionally
195
+ title-guarded so a force-kill can never match our terminal by prefix.
196
+ """
197
+ if selfguard.is_own_title(title_substring):
198
+ raise ValueError(
199
+ "Refusing to close SeedCode's own terminal. Use /exit in the "
200
+ "REPL to quit SeedCode."
201
+ )
202
+ win = _find(title_substring)
203
+ title = win.title
204
+ win.close()
205
+ if force:
206
+ # Best-effort process kill for apps that ignore WM_CLOSE. The window
207
+ # filter is matched as a prefix, so pin it away from our own title.
208
+ if selfguard.is_own_title(title):
209
+ return f"Closed '{title}'." # graceful close succeeded; skip the kill
210
+ subprocess.run(
211
+ f'taskkill /FI "WINDOWTITLE eq {title}*" /F',
212
+ shell=True,
213
+ capture_output=True,
214
+ text=True,
215
+ timeout=15,
216
+ )
217
+ return f"Closed '{title}'."
@@ -0,0 +1,8 @@
1
+ """Configuration system: load/save the app config and its defaults."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from .defaults import CONFIG_FILENAME, ENV_KEYS
6
+ from .manager import load_config, save_config
7
+
8
+ __all__ = ["CONFIG_FILENAME", "ENV_KEYS", "load_config", "save_config"]
@@ -0,0 +1,22 @@
1
+ """Configuration defaults and environment overrides.
2
+
3
+ Kept import-light (no project imports) so it can be referenced from anywhere
4
+ without risk of an import cycle.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ # Environment variables that override stored API keys (CI / power users),
10
+ # keyed by provider id. Ollama needs no key so it has no entry. Values are
11
+ # tuples so a provider can accept several variable names if ever needed.
12
+ ENV_KEYS: dict[str, tuple[str, ...]] = {
13
+ "openrouter": ("OPENROUTER_API_KEY",),
14
+ # One FreeModel account key works on both FreeModel backends; each
15
+ # provider still stores it in its OWN config slot.
16
+ "freemodel_claude": ("FREEMODEL_API_KEY",),
17
+ "freemodel_codex": ("FREEMODEL_API_KEY",),
18
+ "aerolink": ("AEROLINK_API_KEY",),
19
+ }
20
+
21
+ # Filename of the JSON config document within the per-user app directory.
22
+ CONFIG_FILENAME = "config.json"
@@ -0,0 +1,62 @@
1
+ """Configuration loading and saving for Seed Code.
2
+
3
+ The config is a single JSON document under ``~/.seedcode/config.json``. Loading
4
+ is fault-tolerant: a missing or corrupt file yields sane defaults rather than a
5
+ crash, honouring the rule "never crash". Saving is best-effort for the same
6
+ reason (a locked or full disk must not kill the session).
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import json
12
+ import os
13
+
14
+ from ..core.models import AppConfig
15
+ from ..utils.helpers import config_path, restrict_permissions
16
+ from ..utils.logger import get_logger
17
+ from .defaults import ENV_KEYS
18
+
19
+ _log = get_logger("config")
20
+
21
+
22
+ def load_config() -> AppConfig:
23
+ """Load configuration from disk, falling back to defaults on any error."""
24
+ path = config_path()
25
+ config = AppConfig()
26
+
27
+ if path.exists():
28
+ try:
29
+ raw = json.loads(path.read_text(encoding="utf-8"))
30
+ config = AppConfig.model_validate(raw)
31
+ _log.info("config loaded from %s", path)
32
+ except (json.JSONDecodeError, ValueError, OSError) as exc:
33
+ # Corrupt or unreadable config -> start from defaults instead of dying.
34
+ _log.warning("config unreadable (%s); using defaults", exc)
35
+ config = AppConfig()
36
+ else:
37
+ _log.info("no config file yet (first run)")
38
+
39
+ # Explicit environment variables always win over stored API keys.
40
+ for provider_id, env_names in ENV_KEYS.items():
41
+ for env_name in env_names:
42
+ env_key = os.environ.get(env_name, "").strip()
43
+ if env_key:
44
+ config.set_api_key(provider_id, env_key)
45
+ _log.info("api key for %s taken from %s", provider_id, env_name)
46
+ break
47
+
48
+ return config
49
+
50
+
51
+ def save_config(config: AppConfig) -> None:
52
+ """Persist configuration to disk (best-effort, owner-only permissions)."""
53
+ path = config_path()
54
+ try:
55
+ path.write_text(
56
+ json.dumps(config.model_dump(), indent=2, ensure_ascii=False),
57
+ encoding="utf-8",
58
+ )
59
+ restrict_permissions(path)
60
+ except OSError as exc:
61
+ # Settings still apply for this session; only persistence failed.
62
+ _log.error("could not save config to %s: %s", path, exc)
@@ -0,0 +1,31 @@
1
+ """Core logic: data models, provider backends, chat engine, streaming."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from .chat import ChatEngine, ChatError
6
+ from .models import AppConfig, Message
7
+ from .providers import (
8
+ PROVIDERS,
9
+ ModelInfo,
10
+ Provider,
11
+ ProviderError,
12
+ ValidationResult,
13
+ get_provider,
14
+ provider_label,
15
+ )
16
+ from .streaming import iter_stream
17
+
18
+ __all__ = [
19
+ "AppConfig",
20
+ "ChatEngine",
21
+ "ChatError",
22
+ "Message",
23
+ "ModelInfo",
24
+ "PROVIDERS",
25
+ "Provider",
26
+ "ProviderError",
27
+ "ValidationResult",
28
+ "get_provider",
29
+ "iter_stream",
30
+ "provider_label",
31
+ ]