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,241 @@
|
|
|
1
|
+
"""Application discovery across trusted Windows sources.
|
|
2
|
+
|
|
3
|
+
The ladder, cheapest and most trustworthy first (all injectable):
|
|
4
|
+
|
|
5
|
+
1. **Start Menu shortcuts** (``.lnk``) — the canonical user-visible app list;
|
|
6
|
+
both per-user and all-users Start Menus are scanned.
|
|
7
|
+
2. **App Paths registry** — per-machine/per-user registered executables
|
|
8
|
+
(``HKLM\\...\\App Paths\\spotify.exe``).
|
|
9
|
+
3. **Uninstall registrations** — installed-app metadata (name, install
|
|
10
|
+
location, display icon), the same source "Add/Remove Programs" reads.
|
|
11
|
+
4. **PATH lookup** — ``shutil.which`` for CLI tools and portable apps.
|
|
12
|
+
|
|
13
|
+
Every source is matched case-insensitively on the requested name; the
|
|
14
|
+
requester gets one :class:`AppInfo` with the safest available launch target.
|
|
15
|
+
Non-Windows platforms return "not found" rather than guessing.
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
from __future__ import annotations
|
|
19
|
+
|
|
20
|
+
import os
|
|
21
|
+
import shutil
|
|
22
|
+
from dataclasses import dataclass, field
|
|
23
|
+
from pathlib import Path
|
|
24
|
+
from typing import Any
|
|
25
|
+
|
|
26
|
+
from ..core.errors import ApplicationNotFoundError
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
@dataclass(slots=True)
|
|
30
|
+
class AppInfo:
|
|
31
|
+
"""One discovered application."""
|
|
32
|
+
|
|
33
|
+
name: str
|
|
34
|
+
source: str # start_menu | app_paths | uninstall | path
|
|
35
|
+
target: str = "" # shortcut path or executable
|
|
36
|
+
exe: str = "" # resolved executable path ("" when unknown)
|
|
37
|
+
install_location: str = ""
|
|
38
|
+
pid: int = 0 # set by the launcher/verifier, not discovery
|
|
39
|
+
matched: str = "" # which discovery key matched
|
|
40
|
+
|
|
41
|
+
def describe(self) -> str:
|
|
42
|
+
via = f" via {self.source}" if self.source else ""
|
|
43
|
+
return f'"{self.name}"{via}'
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
# Directory-name fragments for the two Start Menu program folders.
|
|
47
|
+
_START_MENU_DIRS = (
|
|
48
|
+
Path(os.environ.get("APPDATA", "")) / "Microsoft" / "Windows" / "Start Menu" / "Programs",
|
|
49
|
+
Path(os.environ.get("PROGRAMDATA", "")) / "Microsoft" / "Windows" / "Start Menu" / "Programs",
|
|
50
|
+
)
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def _iter_start_menu() -> list[Path]:
|
|
54
|
+
shortcuts: list[Path] = []
|
|
55
|
+
for base in _START_MENU_DIRS:
|
|
56
|
+
try:
|
|
57
|
+
if base.is_dir():
|
|
58
|
+
shortcuts.extend(base.rglob("*.lnk"))
|
|
59
|
+
except OSError:
|
|
60
|
+
continue
|
|
61
|
+
return shortcuts
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def _shortcut_display_name(path: Path) -> str:
|
|
65
|
+
"""A .lnk file's user-visible name: its stem (no resolution needed)."""
|
|
66
|
+
return path.stem.strip()
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def _match_name(haystack: str, needle: str) -> bool:
|
|
70
|
+
"""Case-insensitive whole-word-ish containment match."""
|
|
71
|
+
h = " ".join(haystack.lower().split())
|
|
72
|
+
n = " ".join(needle.lower().split())
|
|
73
|
+
return bool(n) and (n == h or n in h)
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
# --- source 2: App Paths -----------------------------------------------------------
|
|
77
|
+
def _app_paths() -> dict[str, AppInfo]:
|
|
78
|
+
"""``App Paths`` registrations: exe name -> full path (best-effort)."""
|
|
79
|
+
found: dict[str, AppInfo] = {}
|
|
80
|
+
if os.name != "nt":
|
|
81
|
+
return found
|
|
82
|
+
try:
|
|
83
|
+
import winreg
|
|
84
|
+
|
|
85
|
+
for hive, flag in ((winreg.HKEY_LOCAL_MACHINE, 0), (winreg.HKEY_CURRENT_USER, 0)):
|
|
86
|
+
try:
|
|
87
|
+
root = winreg.OpenKey(hive, r"Software\Microsoft\Windows\CurrentVersion\App Paths", 0, winreg.KEY_READ | flag)
|
|
88
|
+
except OSError:
|
|
89
|
+
continue
|
|
90
|
+
with root:
|
|
91
|
+
i = 0
|
|
92
|
+
while True:
|
|
93
|
+
try:
|
|
94
|
+
subkey_name = winreg.EnumKey(root, i)
|
|
95
|
+
except OSError:
|
|
96
|
+
break
|
|
97
|
+
i += 1
|
|
98
|
+
try:
|
|
99
|
+
with winreg.OpenKey(root, subkey_name) as sub:
|
|
100
|
+
exe_path, _t = winreg.QueryValueEx(sub, "")
|
|
101
|
+
except OSError:
|
|
102
|
+
continue
|
|
103
|
+
name = Path(subkey_name).stem
|
|
104
|
+
found[name.lower()] = AppInfo(
|
|
105
|
+
name=name, source="app_paths",
|
|
106
|
+
target=str(exe_path or ""), exe=str(exe_path or ""),
|
|
107
|
+
matched=subkey_name,
|
|
108
|
+
)
|
|
109
|
+
except ImportError:
|
|
110
|
+
pass
|
|
111
|
+
return found
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
# --- source 3: uninstall registrations ----------------------------------------------
|
|
115
|
+
def _uninstall_apps() -> dict[str, AppInfo]:
|
|
116
|
+
"""Display-name/install-location entries from the uninstall keys."""
|
|
117
|
+
found: dict[str, AppInfo] = {}
|
|
118
|
+
if os.name != "nt":
|
|
119
|
+
return found
|
|
120
|
+
try:
|
|
121
|
+
import winreg
|
|
122
|
+
|
|
123
|
+
paths = (
|
|
124
|
+
r"Software\Microsoft\Windows\CurrentVersion\Uninstall",
|
|
125
|
+
r"Software\WOW6432Node\Microsoft\Windows\CurrentVersion\Uninstall",
|
|
126
|
+
)
|
|
127
|
+
for hive in (winreg.HKEY_LOCAL_MACHINE, winreg.HKEY_CURRENT_USER):
|
|
128
|
+
for subpath in paths:
|
|
129
|
+
try:
|
|
130
|
+
root = winreg.OpenKey(hive, subpath, 0, winreg.KEY_READ)
|
|
131
|
+
except OSError:
|
|
132
|
+
continue
|
|
133
|
+
with root:
|
|
134
|
+
i = 0
|
|
135
|
+
while True:
|
|
136
|
+
try:
|
|
137
|
+
subkey_name = winreg.EnumKey(root, i)
|
|
138
|
+
except OSError:
|
|
139
|
+
break
|
|
140
|
+
i += 1
|
|
141
|
+
try:
|
|
142
|
+
with winreg.OpenKey(root, subkey_name) as sub:
|
|
143
|
+
display, _t = winreg.QueryValueEx(sub, "DisplayName")
|
|
144
|
+
try:
|
|
145
|
+
loc, _t2 = winreg.QueryValueEx(sub, "InstallLocation")
|
|
146
|
+
except OSError:
|
|
147
|
+
loc = ""
|
|
148
|
+
except OSError:
|
|
149
|
+
continue
|
|
150
|
+
name = str(display or "").strip()
|
|
151
|
+
if not name:
|
|
152
|
+
continue
|
|
153
|
+
entry = AppInfo(
|
|
154
|
+
name=name, source="uninstall",
|
|
155
|
+
install_location=str(loc or ""),
|
|
156
|
+
matched=subkey_name,
|
|
157
|
+
)
|
|
158
|
+
found.setdefault(name.lower(), entry)
|
|
159
|
+
except ImportError:
|
|
160
|
+
pass
|
|
161
|
+
return found
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
# --- the discovery facade -------------------------------------------------------------
|
|
165
|
+
def find_app(name: str, *, start_menu: list[Path] | None = None) -> AppInfo:
|
|
166
|
+
"""Locate an installed application by (fuzzy) name.
|
|
167
|
+
|
|
168
|
+
``start_menu`` injects the shortcut list for tests. Raises
|
|
169
|
+
:class:`ApplicationNotFoundError` when every source misses.
|
|
170
|
+
"""
|
|
171
|
+
wanted = (name or "").strip()
|
|
172
|
+
if not wanted:
|
|
173
|
+
raise ApplicationNotFoundError("An application name is required.")
|
|
174
|
+
|
|
175
|
+
# 1) Start Menu shortcuts — the canonical list. Prefer exact stem matches.
|
|
176
|
+
shortcuts = _iter_start_menu() if start_menu is None else start_menu
|
|
177
|
+
exact: Path | None = None
|
|
178
|
+
partial: Path | None = None
|
|
179
|
+
for path in shortcuts:
|
|
180
|
+
display = _shortcut_display_name(path)
|
|
181
|
+
if display.lower() == wanted.lower():
|
|
182
|
+
exact = path
|
|
183
|
+
break
|
|
184
|
+
if partial is None and _match_name(display, wanted):
|
|
185
|
+
partial = path
|
|
186
|
+
chosen = exact or partial
|
|
187
|
+
if chosen is not None:
|
|
188
|
+
return AppInfo(
|
|
189
|
+
name=_shortcut_display_name(chosen), source="start_menu",
|
|
190
|
+
target=str(chosen), matched=str(chosen),
|
|
191
|
+
)
|
|
192
|
+
|
|
193
|
+
# 2) App Paths (spotify.exe -> full path).
|
|
194
|
+
for app_name, info in _app_paths().items():
|
|
195
|
+
if _match_name(app_name, wanted):
|
|
196
|
+
return info
|
|
197
|
+
|
|
198
|
+
# 3) Uninstall registrations (covers apps without Start Menu entries).
|
|
199
|
+
for app_name, info in _uninstall_apps().items():
|
|
200
|
+
if _match_name(app_name, wanted):
|
|
201
|
+
# Uninstall entries know the location but not the exe; a launch
|
|
202
|
+
# attempt will resolve the target from the install location.
|
|
203
|
+
if info.install_location:
|
|
204
|
+
loc = Path(info.install_location)
|
|
205
|
+
for candidate in (wanted.lower(), wanted.lower().replace(" ", "")):
|
|
206
|
+
exe = loc / f"{candidate}.exe"
|
|
207
|
+
if exe.is_file():
|
|
208
|
+
info.exe = str(exe)
|
|
209
|
+
info.target = str(exe)
|
|
210
|
+
break
|
|
211
|
+
return info
|
|
212
|
+
|
|
213
|
+
# 4) PATH — CLI tools and portable apps.
|
|
214
|
+
which = shutil.which(wanted)
|
|
215
|
+
if which:
|
|
216
|
+
return AppInfo(
|
|
217
|
+
name=wanted, source="path", target=which, exe=which, matched=which
|
|
218
|
+
)
|
|
219
|
+
|
|
220
|
+
raise ApplicationNotFoundError(
|
|
221
|
+
f'"{wanted}" is not installed (searched Start Menu, App Paths, '
|
|
222
|
+
"installed-app registrations, and PATH)."
|
|
223
|
+
)
|
|
224
|
+
|
|
225
|
+
|
|
226
|
+
def installed_apps(*, start_menu: list[Path] | None = None) -> list[AppInfo]:
|
|
227
|
+
"""Every discoverable app (Start Menu + App Paths), name-deduplicated.
|
|
228
|
+
|
|
229
|
+
Used by memory and by "what can I open?" queries; not for matching.
|
|
230
|
+
"""
|
|
231
|
+
seen: dict[str, AppInfo] = {}
|
|
232
|
+
shortcuts = _iter_start_menu() if start_menu is None else start_menu
|
|
233
|
+
for path in shortcuts:
|
|
234
|
+
display = _shortcut_display_name(path)
|
|
235
|
+
if display and display.lower() not in seen:
|
|
236
|
+
seen[display.lower()] = AppInfo(
|
|
237
|
+
name=display, source="start_menu", target=str(path), matched=str(path)
|
|
238
|
+
)
|
|
239
|
+
for key, info in _app_paths().items():
|
|
240
|
+
seen.setdefault(key, info)
|
|
241
|
+
return sorted(seen.values(), key=lambda a: a.name.lower())
|
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
"""Permission-gated application installation (missing-app workflow).
|
|
2
|
+
|
|
3
|
+
When a requested application is not installed, the flow is:
|
|
4
|
+
|
|
5
|
+
missing app → resolve a trusted source → SHOW the user exactly what will
|
|
6
|
+
be installed and from where → explicit confirmation (INSTALL category,
|
|
7
|
+
never remembered) → download/execute → verify installed → launch.
|
|
8
|
+
|
|
9
|
+
Security properties:
|
|
10
|
+
|
|
11
|
+
* The INSTALL category is **sensitive**: "always allow" is impossible by
|
|
12
|
+
construction (the permission layer downgrades it), so every install asks.
|
|
13
|
+
* Only **trusted sources** are eligible: Winget (preferred), Microsoft
|
|
14
|
+
Store, or a caller-supplied official vendor URL. Arbitrary URLs the model
|
|
15
|
+
produces are refused by :func:`_trusted_source`.
|
|
16
|
+
* The model cannot mark anything trusted; trust comes from the winget
|
|
17
|
+
manifest registry or a user-supplied URL, never from model output.
|
|
18
|
+
* Installers are executed only after the user confirms the exact source
|
|
19
|
+
shown to them.
|
|
20
|
+
|
|
21
|
+
The actual winget invocation is injectable so tests never touch a system.
|
|
22
|
+
"""
|
|
23
|
+
|
|
24
|
+
from __future__ import annotations
|
|
25
|
+
|
|
26
|
+
import shutil
|
|
27
|
+
import subprocess
|
|
28
|
+
from dataclasses import dataclass
|
|
29
|
+
from typing import Any
|
|
30
|
+
|
|
31
|
+
from ..core.errors import ApplicationNotFoundError, SecurityError
|
|
32
|
+
from .discovery import AppInfo
|
|
33
|
+
|
|
34
|
+
# Seconds before a winget invocation is declared wedged.
|
|
35
|
+
_WINGET_TIMEOUT_S = 600
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
@dataclass(slots=True)
|
|
39
|
+
class InstallPlan:
|
|
40
|
+
"""What would be installed, shown to the user before confirmation."""
|
|
41
|
+
|
|
42
|
+
app_name: str
|
|
43
|
+
mechanism: str # winget | store | manual
|
|
44
|
+
package_id: str = "" # winget package id, when known
|
|
45
|
+
source: str = "" # human-readable source description
|
|
46
|
+
detail: str = ""
|
|
47
|
+
|
|
48
|
+
def describe(self) -> str:
|
|
49
|
+
lines = [f"Install {self.app_name} via {self.mechanism}"]
|
|
50
|
+
if self.package_id:
|
|
51
|
+
lines.append(f" package: {self.package_id}")
|
|
52
|
+
if self.source:
|
|
53
|
+
lines.append(f" source: {self.source}")
|
|
54
|
+
return "\n".join(lines)
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
@dataclass(slots=True)
|
|
58
|
+
class InstallResult:
|
|
59
|
+
success: bool
|
|
60
|
+
app_name: str
|
|
61
|
+
detail: str = ""
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
# --- trusted source resolution ------------------------------------------------------
|
|
65
|
+
def _winget_package(app_name: str, run: Any = None) -> InstallPlan | None:
|
|
66
|
+
"""A winget plan for ``app_name``, or None when winget can't find one."""
|
|
67
|
+
if shutil.which("winget") is None:
|
|
68
|
+
return None
|
|
69
|
+
if run is None:
|
|
70
|
+
def run(args: list[str]) -> subprocess.CompletedProcess:
|
|
71
|
+
return subprocess.run( # noqa: S603 — fixed argv, no shell
|
|
72
|
+
args, capture_output=True, text=True, timeout=60
|
|
73
|
+
)
|
|
74
|
+
try:
|
|
75
|
+
search = run(["winget", "search", "--id", app_name, "--accept-source-agreements"])
|
|
76
|
+
out = (search.stdout or "") + (search.stderr or "")
|
|
77
|
+
# winget prints a table; any non-error row mentioning the name counts.
|
|
78
|
+
if search.returncode == 0 and app_name.lower() in out.lower():
|
|
79
|
+
return InstallPlan(
|
|
80
|
+
app_name=app_name, mechanism="winget", package_id=app_name,
|
|
81
|
+
source="winget (curated package registry)",
|
|
82
|
+
detail=f"winget found a package matching '{app_name}'",
|
|
83
|
+
)
|
|
84
|
+
except Exception:
|
|
85
|
+
return None
|
|
86
|
+
return None
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def resolve_install_plan(app_name: str, *, run: Any = None) -> InstallPlan:
|
|
90
|
+
"""A trusted install plan for a missing app, or an error.
|
|
91
|
+
|
|
92
|
+
Only winget is currently wired; Microsoft Store routes through winget on
|
|
93
|
+
modern Windows. If nothing trusted exists, the user is told to install
|
|
94
|
+
it manually — SeedCode never fetches an arbitrary installer URL.
|
|
95
|
+
"""
|
|
96
|
+
plan = _winget_package(app_name, run=run)
|
|
97
|
+
if plan is not None:
|
|
98
|
+
return plan
|
|
99
|
+
raise SecurityError(
|
|
100
|
+
f'No trusted installation source found for "{app_name}". SeedCode '
|
|
101
|
+
"will not download installers from arbitrary websites. Install it "
|
|
102
|
+
"manually (official site or Microsoft Store), then ask me to open it."
|
|
103
|
+
)
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def install_app(
|
|
107
|
+
app_name: str,
|
|
108
|
+
*,
|
|
109
|
+
confirm: Any,
|
|
110
|
+
run: Any = None,
|
|
111
|
+
find: Any = None,
|
|
112
|
+
launch: Any = None,
|
|
113
|
+
) -> InstallResult:
|
|
114
|
+
"""The guarded install workflow. ``confirm(plan_description) -> bool``.
|
|
115
|
+
|
|
116
|
+
``confirm`` is wired to the interactive permission dialog by the caller
|
|
117
|
+
(skill/tools layer); it is asked AFTER the plan (mechanism + package +
|
|
118
|
+
source) is shown and is the only path to executing anything.
|
|
119
|
+
"""
|
|
120
|
+
from ..core.errors import OperatorError
|
|
121
|
+
|
|
122
|
+
try:
|
|
123
|
+
plan = resolve_install_plan(app_name, run=run)
|
|
124
|
+
except OperatorError:
|
|
125
|
+
raise
|
|
126
|
+
|
|
127
|
+
if not confirm(plan.describe()):
|
|
128
|
+
return InstallResult(
|
|
129
|
+
False, app_name, "installation declined by the user"
|
|
130
|
+
)
|
|
131
|
+
|
|
132
|
+
# Execute via winget with a fixed, audited argv.
|
|
133
|
+
if run is None:
|
|
134
|
+
def run(args: list[str], **kw: Any) -> subprocess.CompletedProcess:
|
|
135
|
+
return subprocess.run( # noqa: S603 — fixed argv, no shell
|
|
136
|
+
args, capture_output=True, text=True, timeout=_WINGET_TIMEOUT_S
|
|
137
|
+
)
|
|
138
|
+
try:
|
|
139
|
+
proc = run([
|
|
140
|
+
"winget", "install", "--id", plan.package_id,
|
|
141
|
+
"--accept-source-agreements", "--accept-package-agreements",
|
|
142
|
+
])
|
|
143
|
+
except Exception as exc:
|
|
144
|
+
return InstallResult(False, app_name, f"installer failed to run: {exc}")
|
|
145
|
+
ok = getattr(proc, "returncode", 1) == 0
|
|
146
|
+
if not ok:
|
|
147
|
+
tail = ((getattr(proc, "stderr", "") or "") or (getattr(proc, "stdout", "") or "")).strip()
|
|
148
|
+
return InstallResult(False, app_name, f"installer returned an error: {tail[:300]}")
|
|
149
|
+
|
|
150
|
+
# Verify installation actually happened (discovery must now find it).
|
|
151
|
+
if find is None:
|
|
152
|
+
from .discovery import find_app as find
|
|
153
|
+
try:
|
|
154
|
+
find(app_name)
|
|
155
|
+
except ApplicationNotFoundError:
|
|
156
|
+
return InstallResult(
|
|
157
|
+
False, app_name,
|
|
158
|
+
"installer reported success but the app is not discoverable yet; "
|
|
159
|
+
"it may need a moment or a fresh Start Menu index",
|
|
160
|
+
)
|
|
161
|
+
return InstallResult(True, app_name, f"installed {app_name} via {plan.mechanism}")
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
__all__ = ["InstallPlan", "InstallResult", "resolve_install_plan", "install_app"]
|
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
"""Application launching with state-based waiting and duplicate avoidance.
|
|
2
|
+
|
|
3
|
+
The launch workflow never spawns blindly:
|
|
4
|
+
|
|
5
|
+
1. **Running?** Match open windows against the app name (via the windows
|
|
6
|
+
driver, which already excludes SeedCode's own console). A visible window
|
|
7
|
+
means *focus the existing instance* instead of launching a duplicate.
|
|
8
|
+
2. **Launch** through the safest target: a Start Menu shortcut
|
|
9
|
+
(``os.startfile`` resolves it), then a resolved executable, then the
|
|
10
|
+
shell ``start`` fallback — never a guessed bare ``subprocess`` call.
|
|
11
|
+
3. **Wait for state, not for a timer:** poll for a matching window/process
|
|
12
|
+
up to ``MAX_WAIT_WINDOW_S`` instead of a fixed sleep.
|
|
13
|
+
4. **Verify** and return a structured result; the caller (skill) reports
|
|
14
|
+
success only when evidence exists.
|
|
15
|
+
|
|
16
|
+
All OS interaction is injectable for tests.
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
from __future__ import annotations
|
|
20
|
+
|
|
21
|
+
import os
|
|
22
|
+
import subprocess
|
|
23
|
+
import time
|
|
24
|
+
from dataclasses import dataclass
|
|
25
|
+
from typing import Any
|
|
26
|
+
|
|
27
|
+
from ..core.errors import ApplicationLaunchError, ApplicationNotFoundError
|
|
28
|
+
from ..core.limits import MAX_WAIT_WINDOW_S, MAX_WAIT_POLL_S, clamp_wait
|
|
29
|
+
from .discovery import AppInfo, find_app
|
|
30
|
+
from .verifier import app_running, wait_for_app_window
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
@dataclass(slots=True)
|
|
34
|
+
class LaunchResult:
|
|
35
|
+
"""Structured outcome of one open-app attempt."""
|
|
36
|
+
|
|
37
|
+
success: bool
|
|
38
|
+
application: str
|
|
39
|
+
focused_existing: bool = False
|
|
40
|
+
window_detected: bool = False
|
|
41
|
+
window_title: str = ""
|
|
42
|
+
pid: int = 0
|
|
43
|
+
detail: str = ""
|
|
44
|
+
|
|
45
|
+
def describe(self) -> str:
|
|
46
|
+
return self.detail or (f"{self.application}: "
|
|
47
|
+
+ ("focused existing window" if self.focused_existing
|
|
48
|
+
else "launched")
|
|
49
|
+
+ (" (window verified)" if self.window_detected else ""))
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def focus_existing(app: AppInfo, windows_driver: Any = None) -> LaunchResult | None:
|
|
53
|
+
"""Focus a running instance if one exists; None when not running.
|
|
54
|
+
|
|
55
|
+
Matching uses the window list (titles already exclude our console) and,
|
|
56
|
+
where available, the owning process name — never a blind title guess.
|
|
57
|
+
"""
|
|
58
|
+
if windows_driver is None:
|
|
59
|
+
from ..computer import windows as windows_driver # type: ignore
|
|
60
|
+
needle = app.name.lower()
|
|
61
|
+
try:
|
|
62
|
+
wins = windows_driver.list_windows()
|
|
63
|
+
except Exception:
|
|
64
|
+
return None
|
|
65
|
+
for w in wins:
|
|
66
|
+
title = (getattr(w, "title", "") or "").lower()
|
|
67
|
+
if needle in title:
|
|
68
|
+
try:
|
|
69
|
+
windows_driver.focus_window(w.title)
|
|
70
|
+
except Exception:
|
|
71
|
+
pass # focus is best-effort; window evidence stands
|
|
72
|
+
return LaunchResult(
|
|
73
|
+
success=True, application=app.name, focused_existing=True,
|
|
74
|
+
window_detected=True, window_title=getattr(w, "title", ""),
|
|
75
|
+
pid=int(getattr(w, "pid", 0) or 0),
|
|
76
|
+
detail=f'focused existing window "{getattr(w, "title", "")}"',
|
|
77
|
+
)
|
|
78
|
+
return None
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def launch_app(
|
|
82
|
+
name: str,
|
|
83
|
+
*,
|
|
84
|
+
find: Any = find_app,
|
|
85
|
+
windows_driver: Any = None,
|
|
86
|
+
startfile: Any = None,
|
|
87
|
+
popen: Any = None,
|
|
88
|
+
wait_s: float = MAX_WAIT_WINDOW_S,
|
|
89
|
+
) -> LaunchResult:
|
|
90
|
+
"""Open an application: reuse → launch → wait → verify.
|
|
91
|
+
|
|
92
|
+
Injectable parameters exist purely for tests; production callers pass
|
|
93
|
+
only ``name``.
|
|
94
|
+
"""
|
|
95
|
+
if startfile is None:
|
|
96
|
+
def startfile(target: str) -> None:
|
|
97
|
+
os.startfile(target) # noqa: S606 — shell-resolved launch
|
|
98
|
+
if popen is None:
|
|
99
|
+
def popen(argv: list[str]) -> subprocess.Popen:
|
|
100
|
+
return subprocess.Popen( # noqa: S603 — resolved target only
|
|
101
|
+
argv, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL,
|
|
102
|
+
stdin=subprocess.DEVNULL,
|
|
103
|
+
)
|
|
104
|
+
|
|
105
|
+
try:
|
|
106
|
+
app = find(name)
|
|
107
|
+
except ApplicationNotFoundError:
|
|
108
|
+
raise # the caller (skill) turns this into the install-permission flow
|
|
109
|
+
|
|
110
|
+
# Step 1: already running? Focus, don't duplicate.
|
|
111
|
+
existing = focus_existing(app, windows_driver)
|
|
112
|
+
if existing is not None:
|
|
113
|
+
return existing
|
|
114
|
+
|
|
115
|
+
# Step 2: launch via the safest target.
|
|
116
|
+
target = app.target or app.exe
|
|
117
|
+
if not target:
|
|
118
|
+
raise ApplicationLaunchError(
|
|
119
|
+
f'"{app.name}" is installed but exposes no launchable target.'
|
|
120
|
+
)
|
|
121
|
+
launched_via = "shortcut" if app.source == "start_menu" else "executable"
|
|
122
|
+
try:
|
|
123
|
+
if app.source == "start_menu" or target.lower().endswith(".lnk"):
|
|
124
|
+
startfile(target)
|
|
125
|
+
else:
|
|
126
|
+
popen([target])
|
|
127
|
+
except OSError as exc:
|
|
128
|
+
raise ApplicationLaunchError(f'Could not launch "{app.name}": {exc}')
|
|
129
|
+
|
|
130
|
+
# Step 3+4: state-based wait for a real window (no blind sleep).
|
|
131
|
+
window = wait_for_app_window(app, windows_driver, timeout_s=wait_s)
|
|
132
|
+
if window is not None:
|
|
133
|
+
return LaunchResult(
|
|
134
|
+
success=True, application=app.name, window_detected=True,
|
|
135
|
+
window_title=window, pid=app.pid,
|
|
136
|
+
detail=f'launched {app.name} via {launched_via}; window "{window}" detected',
|
|
137
|
+
)
|
|
138
|
+
# No window yet: accept a running process as weaker evidence.
|
|
139
|
+
pid = app_running(app, windows_driver)
|
|
140
|
+
if pid:
|
|
141
|
+
return LaunchResult(
|
|
142
|
+
success=True, application=app.name, window_detected=False,
|
|
143
|
+
pid=pid, detail=f"launched {app.name} (process running, no window yet)",
|
|
144
|
+
)
|
|
145
|
+
return LaunchResult(
|
|
146
|
+
success=False, application=app.name,
|
|
147
|
+
detail=f"launched {app.name} but no window or process evidence within {wait_s:g}s",
|
|
148
|
+
)
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
def open_or_raise(name: str, **kw: Any) -> LaunchResult:
|
|
152
|
+
"""Convenience wrapper: find+launch, translating discovery misses."""
|
|
153
|
+
return launch_app(name, **kw)
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
__all__ = ["LaunchResult", "launch_app", "focus_existing", "open_or_raise"]
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
"""Application launch verification: evidence, not assumptions.
|
|
2
|
+
|
|
3
|
+
``launch`` alone proves nothing on Windows — the shell accepts the request
|
|
4
|
+
and the app may crash a second later. This module checks for *evidence*
|
|
5
|
+
that the application actually came up:
|
|
6
|
+
|
|
7
|
+
* a window whose title matches the app (strong evidence), or
|
|
8
|
+
* a running process whose name matches (weaker evidence), via the window
|
|
9
|
+
list's owning pids (psutil when present, best-effort).
|
|
10
|
+
|
|
11
|
+
``wait_for_app_window`` polls — it never sleeps blindly — and returns the
|
|
12
|
+
matched window title, or None after the bounded timeout.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
import time
|
|
18
|
+
from typing import Any
|
|
19
|
+
|
|
20
|
+
from ..core.limits import MAX_WAIT_WINDOW_S, MAX_WAIT_POLL_S, clamp_wait
|
|
21
|
+
from .discovery import AppInfo
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def _process_names() -> set[int]:
|
|
25
|
+
"""Pids of running processes whose name plausibly matches apps (best-effort)."""
|
|
26
|
+
pids: set[int] = set()
|
|
27
|
+
try:
|
|
28
|
+
import psutil # type: ignore
|
|
29
|
+
|
|
30
|
+
for proc in psutil.process_iter(["pid"]):
|
|
31
|
+
try:
|
|
32
|
+
pids.add(int(proc.info["pid"]))
|
|
33
|
+
except Exception:
|
|
34
|
+
continue
|
|
35
|
+
except Exception:
|
|
36
|
+
pass
|
|
37
|
+
return pids
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def _window_fragments(app: AppInfo) -> list[str]:
|
|
41
|
+
"""Title fragments that identify this app's windows."""
|
|
42
|
+
name = (app.name or "").lower().strip()
|
|
43
|
+
fragments = [name] if name else []
|
|
44
|
+
exe = (app.exe or "")
|
|
45
|
+
if exe:
|
|
46
|
+
stem = exe.replace("\\", "/").rsplit("/", 1)[-1]
|
|
47
|
+
stem = stem[:-4] if stem.lower().endswith(".exe") else stem
|
|
48
|
+
if stem:
|
|
49
|
+
fragments.append(stem.lower())
|
|
50
|
+
return fragments
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def find_app_window(app: AppInfo, windows_driver: Any = None) -> str | None:
|
|
54
|
+
"""The title of a window belonging to ``app``, or None."""
|
|
55
|
+
if windows_driver is None:
|
|
56
|
+
from ..computer import windows as windows_driver # type: ignore
|
|
57
|
+
try:
|
|
58
|
+
wins = windows_driver.list_windows()
|
|
59
|
+
except Exception:
|
|
60
|
+
return None
|
|
61
|
+
fragments = _window_fragments(app)
|
|
62
|
+
# Prefer process-id equality when the window carries one.
|
|
63
|
+
for w in wins:
|
|
64
|
+
if app.pid and int(getattr(w, "pid", 0) or 0) == app.pid:
|
|
65
|
+
return getattr(w, "title", "")
|
|
66
|
+
for w in wins:
|
|
67
|
+
title = (getattr(w, "title", "") or "").lower()
|
|
68
|
+
if any(f and f in title for f in fragments):
|
|
69
|
+
return getattr(w, "title", "")
|
|
70
|
+
return None
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def app_running(app: AppInfo, windows_driver: Any = None) -> int:
|
|
74
|
+
"""The app's running pid (from windows/processes), or 0."""
|
|
75
|
+
if windows_driver is None:
|
|
76
|
+
from ..computer import windows as windows_driver # type: ignore
|
|
77
|
+
try:
|
|
78
|
+
wins = windows_driver.list_windows()
|
|
79
|
+
except Exception:
|
|
80
|
+
wins = []
|
|
81
|
+
fragments = _window_fragments(app)
|
|
82
|
+
for w in wins:
|
|
83
|
+
title = (getattr(w, "title", "") or "").lower()
|
|
84
|
+
if any(f and f in title for f in fragments):
|
|
85
|
+
pid = int(getattr(w, "pid", 0) or 0)
|
|
86
|
+
if pid:
|
|
87
|
+
return pid
|
|
88
|
+
# No window: consult live processes by exe stem.
|
|
89
|
+
live = _process_names()
|
|
90
|
+
if not live:
|
|
91
|
+
return 0
|
|
92
|
+
try:
|
|
93
|
+
import psutil # type: ignore
|
|
94
|
+
|
|
95
|
+
exe_stem = (app.exe or "").replace("\\", "/").rsplit("/", 1)[-1].lower()
|
|
96
|
+
for proc in psutil.process_iter(["pid", "name"]):
|
|
97
|
+
name = (proc.info.get("name") or "").lower()
|
|
98
|
+
if exe_stem and name == exe_stem:
|
|
99
|
+
return int(proc.info["pid"])
|
|
100
|
+
except Exception:
|
|
101
|
+
pass
|
|
102
|
+
return 0
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def wait_for_app_window(
|
|
106
|
+
app: AppInfo, windows_driver: Any = None, timeout_s: float = MAX_WAIT_WINDOW_S
|
|
107
|
+
) -> str | None:
|
|
108
|
+
"""Poll for the app's window until it appears or the timeout elapses."""
|
|
109
|
+
deadline = time.monotonic() + clamp_wait(timeout_s, MAX_WAIT_WINDOW_S)
|
|
110
|
+
while True:
|
|
111
|
+
title = find_app_window(app, windows_driver)
|
|
112
|
+
if title:
|
|
113
|
+
return title
|
|
114
|
+
if time.monotonic() >= deadline:
|
|
115
|
+
return None
|
|
116
|
+
time.sleep(MAX_WAIT_POLL_S)
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
__all__ = ["find_app_window", "app_running", "wait_for_app_window"]
|
seedcode/assets/logo.txt
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
███████╗███████╗███████╗██████╗ ██████╗ ██████╗ ██████╗ ███████╗
|
|
2
|
+
██╔════╝██╔════╝██╔════╝██╔══██╗ ██╔════╝██╔═══██╗██╔══██╗██╔════╝
|
|
3
|
+
███████╗█████╗ █████╗ ██║ ██║ ██║ ██║ ██║██║ ██║█████╗
|
|
4
|
+
╚════██║██╔══╝ ██╔══╝ ██║ ██║ ██║ ██║ ██║██║ ██║██╔══╝
|
|
5
|
+
███████║███████╗███████╗██████╔╝ ╚██████╗╚██████╔╝██████╔╝███████╗
|
|
6
|
+
╚══════╝╚══════╝╚══════╝╚═════╝ ╚═════╝ ╚═════╝ ╚═════╝ ╚══════╝
|
|
7
|
+
|
|
8
|
+
S E E D C O D E
|
|
9
|
+
|
|
10
|
+
Plant ideas. Grow code.
|
|
11
|
+
|
|
12
|
+
Created by
|
|
13
|
+
Al Shahriar Sowan
|
|
14
|
+
|
|
15
|
+
Vibe coded with GPT-5o & Claude Opus 4.8
|