ltcai 9.0.0 → 9.2.0

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 (189) hide show
  1. package/README.md +88 -46
  2. package/auto_setup.py +7 -853
  3. package/desktop/electron/README.md +9 -0
  4. package/desktop/electron/main.cjs +5 -3
  5. package/docs/CHANGELOG.md +178 -2
  6. package/docs/COMMUNITY_AND_PLUGINS.md +4 -2
  7. package/docs/DEVELOPMENT.md +53 -14
  8. package/docs/LEGACY_COMPATIBILITY.md +4 -4
  9. package/docs/ONBOARDING.md +10 -2
  10. package/docs/OPERATIONS.md +8 -4
  11. package/docs/PRODUCT_DIRECTION_REVIEW.md +4 -0
  12. package/docs/TRUST_MODEL.md +5 -2
  13. package/docs/WHY_LATTICE.md +15 -10
  14. package/docs/WORKFLOW_DESIGNER.md +22 -0
  15. package/docs/kg-schema.md +13 -2
  16. package/docs/mcp-tools.md +17 -6
  17. package/docs/privacy.md +19 -3
  18. package/docs/public-deploy.md +32 -3
  19. package/docs/security-model.md +46 -11
  20. package/lattice_brain/__init__.py +1 -1
  21. package/lattice_brain/archive.py +1 -6
  22. package/lattice_brain/context.py +66 -9
  23. package/lattice_brain/graph/_kg_fsutil.py +1 -5
  24. package/lattice_brain/graph/discovery_index.py +174 -20
  25. package/lattice_brain/graph/documents.py +44 -9
  26. package/lattice_brain/graph/ingest.py +47 -20
  27. package/lattice_brain/graph/provenance.py +13 -6
  28. package/lattice_brain/graph/retrieval.py +140 -39
  29. package/lattice_brain/graph/retrieval_docgen.py +37 -4
  30. package/lattice_brain/ingestion.py +4 -7
  31. package/lattice_brain/portability.py +28 -14
  32. package/lattice_brain/runtime/agent_runtime.py +5 -9
  33. package/lattice_brain/runtime/hooks.py +30 -13
  34. package/lattice_brain/runtime/multi_agent.py +27 -8
  35. package/lattice_brain/runtime/statuses.py +10 -0
  36. package/lattice_brain/utils.py +20 -2
  37. package/lattice_brain/workflow.py +1 -5
  38. package/latticeai/__init__.py +1 -1
  39. package/latticeai/api/admin.py +1 -1
  40. package/latticeai/api/agent_registry.py +10 -8
  41. package/latticeai/api/agents.py +22 -3
  42. package/latticeai/api/auth.py +78 -16
  43. package/latticeai/api/browser.py +303 -34
  44. package/latticeai/api/chat.py +320 -850
  45. package/latticeai/api/chat_agent_http.py +356 -0
  46. package/latticeai/api/chat_contracts.py +54 -0
  47. package/latticeai/api/chat_documents.py +256 -0
  48. package/latticeai/api/chat_helpers.py +28 -1
  49. package/latticeai/api/chat_history.py +99 -0
  50. package/latticeai/api/chat_intents.py +316 -0
  51. package/latticeai/api/chat_stream.py +115 -0
  52. package/latticeai/api/computer_use.py +62 -9
  53. package/latticeai/api/health.py +9 -3
  54. package/latticeai/api/hooks.py +17 -6
  55. package/latticeai/api/knowledge_graph.py +78 -14
  56. package/latticeai/api/local_files.py +7 -2
  57. package/latticeai/api/mcp.py +102 -23
  58. package/latticeai/api/models.py +72 -33
  59. package/latticeai/api/network.py +5 -5
  60. package/latticeai/api/permissions.py +67 -26
  61. package/latticeai/api/plugins.py +21 -7
  62. package/latticeai/api/portability.py +5 -5
  63. package/latticeai/api/realtime.py +33 -5
  64. package/latticeai/api/setup.py +2 -2
  65. package/latticeai/api/static_routes.py +123 -24
  66. package/latticeai/api/tools.py +96 -14
  67. package/latticeai/api/workflow_designer.py +37 -0
  68. package/latticeai/api/workspace.py +2 -1
  69. package/latticeai/app_factory.py +110 -52
  70. package/latticeai/core/agent.py +50 -13
  71. package/latticeai/core/agent_prompts.py +5 -0
  72. package/latticeai/core/agent_registry.py +1 -5
  73. package/latticeai/core/config.py +23 -3
  74. package/latticeai/core/context_builder.py +21 -3
  75. package/latticeai/core/file_generation.py +451 -0
  76. package/latticeai/core/invitations.py +1 -4
  77. package/latticeai/core/io_utils.py +9 -1
  78. package/latticeai/core/legacy_compatibility.py +24 -3
  79. package/latticeai/core/marketplace.py +1 -1
  80. package/latticeai/core/plugins.py +15 -0
  81. package/latticeai/core/policy.py +1 -1
  82. package/latticeai/core/realtime.py +38 -15
  83. package/latticeai/core/sessions.py +7 -2
  84. package/latticeai/core/timeutil.py +10 -0
  85. package/latticeai/core/tool_registry.py +90 -18
  86. package/latticeai/core/users.py +1 -5
  87. package/latticeai/core/workspace_graph_trace.py +31 -5
  88. package/latticeai/core/workspace_memory.py +3 -1
  89. package/latticeai/core/workspace_os.py +18 -7
  90. package/latticeai/core/workspace_os_utils.py +0 -5
  91. package/latticeai/core/workspace_permissions.py +2 -1
  92. package/latticeai/core/workspace_plugins.py +1 -1
  93. package/latticeai/core/workspace_runs.py +14 -5
  94. package/latticeai/core/workspace_skills.py +1 -1
  95. package/latticeai/core/workspace_snapshots.py +2 -1
  96. package/latticeai/core/workspace_timeline.py +2 -1
  97. package/latticeai/integrations/telegram_bot.py +94 -43
  98. package/latticeai/models/router.py +130 -36
  99. package/latticeai/runtime/access_runtime.py +62 -4
  100. package/latticeai/runtime/automation_runtime.py +13 -7
  101. package/latticeai/runtime/brain_runtime.py +19 -7
  102. package/latticeai/runtime/chat_wiring.py +2 -0
  103. package/latticeai/runtime/config_runtime.py +58 -26
  104. package/latticeai/runtime/context_runtime.py +16 -4
  105. package/latticeai/runtime/hooks_runtime.py +1 -1
  106. package/latticeai/runtime/model_wiring.py +33 -5
  107. package/latticeai/runtime/namespace_runtime.py +62 -72
  108. package/latticeai/runtime/platform_runtime_wiring.py +4 -3
  109. package/latticeai/runtime/review_wiring.py +1 -1
  110. package/latticeai/runtime/router_registration.py +34 -1
  111. package/latticeai/runtime/security_runtime.py +110 -13
  112. package/latticeai/runtime/stages.py +27 -0
  113. package/latticeai/server_app.py +5 -1
  114. package/latticeai/services/architecture_readiness.py +74 -5
  115. package/latticeai/services/brain_automation.py +3 -26
  116. package/latticeai/services/chat_service.py +205 -15
  117. package/latticeai/services/local_knowledge.py +423 -0
  118. package/latticeai/services/memory_service.py +55 -21
  119. package/latticeai/services/model_engines.py +48 -33
  120. package/latticeai/services/model_errors.py +17 -0
  121. package/latticeai/services/model_loading.py +28 -25
  122. package/latticeai/services/model_runtime.py +228 -162
  123. package/latticeai/services/p_reinforce.py +22 -3
  124. package/latticeai/services/platform_runtime.py +83 -23
  125. package/latticeai/services/product_readiness.py +19 -17
  126. package/latticeai/services/review_queue.py +12 -0
  127. package/latticeai/services/router_context.py +1 -0
  128. package/latticeai/services/run_executor.py +4 -9
  129. package/latticeai/services/search_service.py +203 -28
  130. package/latticeai/services/tool_dispatch.py +28 -5
  131. package/latticeai/services/triggers.py +53 -4
  132. package/latticeai/services/upload_service.py +13 -2
  133. package/latticeai/setup/__init__.py +25 -0
  134. package/latticeai/setup/auto_setup.py +857 -0
  135. package/latticeai/setup/wizard.py +1264 -0
  136. package/latticeai/tools/__init__.py +278 -0
  137. package/{tools → latticeai/tools}/commands.py +67 -7
  138. package/{tools → latticeai/tools}/computer.py +1 -1
  139. package/{tools → latticeai/tools}/documents.py +1 -1
  140. package/{tools → latticeai/tools}/filesystem.py +3 -3
  141. package/latticeai/tools/knowledge.py +178 -0
  142. package/{tools → latticeai/tools}/local_files.py +1 -1
  143. package/{tools → latticeai/tools}/network.py +0 -1
  144. package/local_knowledge_api.py +4 -342
  145. package/package.json +22 -2
  146. package/scripts/brain_quality_eval.py +3 -1
  147. package/scripts/bump_version.py +3 -0
  148. package/scripts/capture_release_evidence.mjs +180 -0
  149. package/scripts/check_current_release_docs.mjs +142 -0
  150. package/scripts/check_i18n_literals.mjs +107 -31
  151. package/scripts/check_openapi_drift.mjs +56 -0
  152. package/scripts/export_openapi.py +67 -7
  153. package/scripts/i18n_literal_allowlist.json +22 -24
  154. package/scripts/run_integration_tests.mjs +72 -10
  155. package/scripts/validate_release_artifacts.py +49 -7
  156. package/scripts/wheel_smoke.py +5 -0
  157. package/setup_wizard.py +3 -1260
  158. package/src-tauri/Cargo.lock +1 -1
  159. package/src-tauri/Cargo.toml +1 -1
  160. package/src-tauri/tauri.conf.json +1 -1
  161. package/static/app/asset-manifest.json +11 -11
  162. package/static/app/assets/Act-C3dBrWE-.js +1 -0
  163. package/static/app/assets/Brain-DBYgdcjt.js +321 -0
  164. package/static/app/assets/Capture-Cf3hqRtN.js +1 -0
  165. package/static/app/assets/Library-CFfkNn3s.js +1 -0
  166. package/static/app/assets/System-BOurbT-v.js +1 -0
  167. package/static/app/assets/index-A3M9sElj.js +17 -0
  168. package/static/app/assets/index-Bmx9rzTc.css +2 -0
  169. package/static/app/assets/primitives-DcUUmhdC.js +1 -0
  170. package/static/app/assets/textarea-BklR6zN4.js +1 -0
  171. package/static/app/index.html +2 -2
  172. package/static/sw.js +1 -1
  173. package/tools/__init__.py +21 -269
  174. package/docs/CODE_REVIEW_2026-07-06.md +0 -764
  175. package/latticeai/runtime/app_context_runtime.py +0 -13
  176. package/latticeai/runtime/tail_wiring.py +0 -21
  177. package/scripts/com.pts.claudecode.discord.plist +0 -31
  178. package/scripts/pts-claudecode-discord-bridge.mjs +0 -207
  179. package/scripts/start-pts-claudecode-discord.sh +0 -51
  180. package/static/app/assets/Act-21lIXx2E.js +0 -1
  181. package/static/app/assets/Brain-BqUd5UJJ.js +0 -321
  182. package/static/app/assets/Capture-BA7Z2Q1u.js +0 -1
  183. package/static/app/assets/Library-bFMtyni3.js +0 -1
  184. package/static/app/assets/System-K6krGCqn.js +0 -1
  185. package/static/app/assets/index-C4R3ws30.js +0 -17
  186. package/static/app/assets/index-ChSeOB02.css +0 -2
  187. package/static/app/assets/primitives-sQU3it5I.js +0 -1
  188. package/static/app/assets/textarea-DK3Fd_lR.js +0 -1
  189. package/tools/knowledge.py +0 -95
@@ -0,0 +1,1264 @@
1
+ """
2
+ Smart Setup Wizard — Environment Scanner, Recommender & Auto-Installer
3
+ Detects hardware, tools, and API keys; returns tailored recommendations;
4
+ streams SSE installation progress.
5
+
6
+ Formerly the root ``setup.py``; renamed in v4 so it no longer collides with
7
+ the setuptools build entrypoint and actually ships in the wheel
8
+ (``pyproject.toml`` py-modules). Packaging is owned entirely by
9
+ ``pyproject.toml`` — there is deliberately no root ``setup.py``.
10
+ """
11
+
12
+ import asyncio
13
+ import json as _json
14
+ import os
15
+ import platform
16
+ import re
17
+ import shutil
18
+ import subprocess
19
+ import sys
20
+ import time
21
+ from pathlib import Path
22
+ from typing import Any, AsyncIterator, Dict, List, Tuple
23
+
24
+ from latticeai.services.process_audit import (
25
+ CommandConfirmationError,
26
+ append_process_audit_event,
27
+ command_plan,
28
+ command_plan_for_commands,
29
+ require_command_confirmation,
30
+ )
31
+ from latticeai.services.setup_detection import (
32
+ detect_cuda,
33
+ detect_tools,
34
+ detect_wsl_from_text,
35
+ parse_windows_video_controllers as _parse_windows_video_controllers,
36
+ )
37
+
38
+ # ── Helpers ───────────────────────────────────────────────────────────────────
39
+
40
+ def _cmd(args: List[str], timeout: int = 10) -> str:
41
+ try:
42
+ r = subprocess.run(args, capture_output=True, text=True, timeout=timeout, check=False)
43
+ return (r.stdout or r.stderr or "").strip()
44
+ except Exception:
45
+ return ""
46
+
47
+ def _sse(data: Dict) -> str:
48
+ return f"data: {_json.dumps(data, ensure_ascii=False)}\n\n"
49
+
50
+
51
+ def _action_commands(action: Dict[str, Any]) -> List[List[str]]:
52
+ atype = action.get("type")
53
+ if atype == "pip":
54
+ return [[sys.executable, "-m", "pip", "install", "--upgrade", str(pkg)] for pkg in action.get("packages", [])]
55
+ if atype == "brew":
56
+ package = str(action.get("package") or "")
57
+ return [["brew", "install", package]] if package else []
58
+ return []
59
+
60
+
61
+ def _action_command_plan(action: Dict[str, Any], *, name: str) -> Dict[str, Any] | None:
62
+ commands = _action_commands(action)
63
+ if not commands:
64
+ return None
65
+ return command_plan_for_commands(
66
+ commands,
67
+ name=name,
68
+ purpose="setup_wizard_install",
69
+ metadata={"action_type": action.get("type")},
70
+ )
71
+
72
+
73
+ def _attach_action_plan(action: Dict[str, Any] | None, *, name: str) -> Dict[str, Any] | None:
74
+ if not isinstance(action, dict):
75
+ return action
76
+ plan = _action_command_plan(action, name=name)
77
+ if not plan:
78
+ return action
79
+ hydrated = dict(action)
80
+ hydrated["command_plan"] = plan
81
+ hydrated["confirmation_token"] = plan["confirmation_token"]
82
+ return hydrated
83
+
84
+
85
+ def _hydrate_install_actions(groups: Dict[str, Any]) -> Dict[str, Any]:
86
+ for group_name in ("components", "engines", "models", "mcps"):
87
+ items = groups.get(group_name)
88
+ if not isinstance(items, list):
89
+ continue
90
+ for item in items:
91
+ if isinstance(item, dict):
92
+ item["action"] = _attach_action_plan(
93
+ item.get("action"),
94
+ name=str(item.get("id") or item.get("name") or group_name),
95
+ )
96
+ return groups
97
+
98
+
99
+ def _verify_action_confirmation(action: Dict[str, Any], token: str | None, *, name: str) -> bool:
100
+ plan = _action_command_plan(action, name=name)
101
+ if not plan:
102
+ return True
103
+ return str(token or "").strip() == plan["confirmation_token"]
104
+
105
+ OFFICIAL_DOWNLOADS: Dict[str, str] = {
106
+ "homebrew": "https://brew.sh",
107
+ "python": "https://www.python.org/downloads/",
108
+ "node": "https://nodejs.org/en/download",
109
+ "git": "https://git-scm.com/downloads",
110
+ "ollama": "https://ollama.com/download",
111
+ "lmstudio": "https://lmstudio.ai/download",
112
+ "cuda": "https://developer.nvidia.com/cuda-downloads",
113
+ "mlx": "https://ml-explore.github.io/mlx/build/html/install.html",
114
+ "cloudflared": "https://developers.cloudflare.com/cloudflare-one/connections/connect-networks/downloads/",
115
+ "tesseract": "https://tesseract-ocr.github.io/tessdoc/Installation.html",
116
+ }
117
+
118
+ COMMON_PATH_DIRS = [
119
+ "/opt/homebrew/bin",
120
+ "/usr/local/bin",
121
+ "/usr/bin",
122
+ "/bin",
123
+ str(Path.home() / ".local" / "bin"),
124
+ str(Path.home() / ".cargo" / "bin"),
125
+ str(Path.home() / ".latticeai" / "bin"),
126
+ ]
127
+
128
+ if platform.system() == "Windows":
129
+ _local_appdata = os.environ.get("LOCALAPPDATA", "")
130
+ _program_files = os.environ.get("ProgramFiles", r"C:\Program Files")
131
+ _program_files_x86 = os.environ.get("ProgramFiles(x86)", r"C:\Program Files (x86)")
132
+ COMMON_PATH_DIRS.extend([
133
+ str(Path(_local_appdata) / "Programs" / "Ollama") if _local_appdata else "",
134
+ str(Path(_program_files) / "Ollama"),
135
+ str(Path(_program_files) / "LM Studio"),
136
+ str(Path(_program_files) / "NVIDIA Corporation" / "NVSMI"),
137
+ str(Path(_program_files_x86) / "NVIDIA Corporation" / "NVSMI"),
138
+ ])
139
+ COMMON_PATH_DIRS = [p for p in COMMON_PATH_DIRS if p]
140
+
141
+ WINDOWS_BINARY_CANDIDATES: Dict[str, List[str]] = {
142
+ "ollama": [
143
+ str(Path(os.environ.get("LOCALAPPDATA", "")) / "Programs" / "Ollama" / "ollama.exe"),
144
+ str(Path(os.environ.get("ProgramFiles", r"C:\Program Files")) / "Ollama" / "ollama.exe"),
145
+ ],
146
+ "lms": [
147
+ str(Path(os.environ.get("LOCALAPPDATA", "")) / "Programs" / "LM Studio" / "resources" / "app" / ".webpack" / "lms.exe"),
148
+ str(Path(os.environ.get("ProgramFiles", r"C:\Program Files")) / "LM Studio" / "resources" / "app" / ".webpack" / "lms.exe"),
149
+ ],
150
+ "nvidia-smi": [
151
+ str(Path(os.environ.get("ProgramFiles", r"C:\Program Files")) / "NVIDIA Corporation" / "NVSMI" / "nvidia-smi.exe"),
152
+ str(Path(os.environ.get("ProgramFiles(x86)", r"C:\Program Files (x86)")) / "NVIDIA Corporation" / "NVSMI" / "nvidia-smi.exe"),
153
+ ],
154
+ }
155
+
156
+ PACKAGE_MODULES: Dict[str, str] = {
157
+ "mlx-vlm": "mlx_vlm",
158
+ "huggingface_hub[cli]": "huggingface_hub",
159
+ "openai-whisper": "whisper",
160
+ }
161
+
162
+
163
+ def _project_env_file() -> Path:
164
+ return Path(__file__).resolve().parents[2] / ".env"
165
+
166
+
167
+ def _update_env_file(env_file: Path, key: str, value: str) -> None:
168
+ lines: List[str] = []
169
+ found = False
170
+ if env_file.exists():
171
+ lines = env_file.read_text(encoding="utf-8").splitlines()
172
+ updated: List[str] = []
173
+ for line in lines:
174
+ if line.startswith(f"{key}="):
175
+ updated.append(f"{key}={value}")
176
+ found = True
177
+ else:
178
+ updated.append(line)
179
+ if not found:
180
+ updated.append(f"{key}={value}")
181
+ env_file.write_text("\n".join(updated) + "\n", encoding="utf-8")
182
+
183
+
184
+ def _merge_path_dirs(dirs: List[str]) -> List[str]:
185
+ current = os.environ.get("PATH", "")
186
+ parts = [p for p in current.split(os.pathsep) if p]
187
+ for item in dirs:
188
+ expanded = str(Path(item).expanduser())
189
+ if Path(expanded).exists() and expanded not in parts:
190
+ parts.insert(0, expanded)
191
+ os.environ["PATH"] = os.pathsep.join(parts)
192
+ return parts
193
+
194
+
195
+ def _persist_extra_path(dirs: List[str]) -> None:
196
+ existing = [
197
+ p for p in os.environ.get("LATTICEAI_EXTRA_PATH", "").split(os.pathsep)
198
+ if p
199
+ ]
200
+ merged = existing[:]
201
+ for item in dirs:
202
+ expanded = str(Path(item).expanduser())
203
+ if Path(expanded).exists() and expanded not in merged:
204
+ merged.append(expanded)
205
+ if merged:
206
+ os.environ["LATTICEAI_EXTRA_PATH"] = os.pathsep.join(merged)
207
+ _update_env_file(_project_env_file(), "LATTICEAI_EXTRA_PATH", os.environ["LATTICEAI_EXTRA_PATH"])
208
+
209
+
210
+ def repair_path_for(binary: str | None = None) -> List[str]:
211
+ before = _which_any(binary) if binary else None
212
+ paths = _merge_path_dirs(COMMON_PATH_DIRS)
213
+ if binary and not before and _which_any(binary):
214
+ _persist_extra_path(COMMON_PATH_DIRS)
215
+ return paths
216
+
217
+
218
+ def _which_any(binary: str) -> str | None:
219
+ path = shutil.which(binary)
220
+ if path:
221
+ return path
222
+ if platform.system() == "Windows":
223
+ for candidate in WINDOWS_BINARY_CANDIDATES.get(binary, []):
224
+ if candidate and Path(candidate).exists():
225
+ return candidate
226
+ return None
227
+
228
+
229
+ def _which_detail(binary: str) -> Dict[str, Any]:
230
+ path = _which_any(binary)
231
+ return {"installed": path is not None, "path": path}
232
+
233
+
234
+ def _module_available(module_name: str) -> bool:
235
+ import importlib.util
236
+ return importlib.util.find_spec(module_name) is not None
237
+
238
+
239
+ def _package_module(package: str) -> str:
240
+ return PACKAGE_MODULES.get(package, package.replace("-", "_").split("[", 1)[0])
241
+
242
+
243
+ def _component_detail(name: str, binary: str | None = None, module: str | None = None) -> Dict[str, Any]:
244
+ detail: Dict[str, Any] = {"official_url": OFFICIAL_DOWNLOADS.get(name)}
245
+ if binary:
246
+ detail.update(_which_detail(binary))
247
+ if module:
248
+ detail["module_available"] = _module_available(module)
249
+ detail["installed"] = bool(detail.get("installed") or detail["module_available"])
250
+ return detail
251
+
252
+
253
+ def _verify_binary(binary: str, version_args: List[str] | None = None, timeout: int = 20) -> Tuple[bool, str]:
254
+ repair_path_for(binary)
255
+ found = _which_any(binary)
256
+ if not found:
257
+ return False, f"{binary} 실행 파일을 PATH에서 찾지 못했습니다."
258
+ args = [found, *(version_args or ["--version"])]
259
+ try:
260
+ completed = subprocess.run(args, capture_output=True, text=True, timeout=timeout, check=False)
261
+ except Exception as e:
262
+ return False, str(e)
263
+ output = (completed.stdout or completed.stderr or "").strip().splitlines()
264
+ if completed.returncode == 0:
265
+ return True, output[0] if output else found
266
+ return False, (completed.stderr or completed.stdout or f"returncode={completed.returncode}")[-400:]
267
+
268
+
269
+ async def _wait_for_binary(binary: str, seconds: int = 300) -> Tuple[bool, str]:
270
+ deadline = time.time() + seconds
271
+ while time.time() < deadline:
272
+ ok, msg = _verify_binary(binary)
273
+ if ok:
274
+ return True, msg
275
+ await asyncio.sleep(2)
276
+ return False, f"{binary} 설치 완료를 제한 시간 안에 감지하지 못했습니다."
277
+
278
+ # ── Environment Detection ─────────────────────────────────────────────────────
279
+
280
+ def _detect_chip() -> Dict[str, Any]:
281
+ arch = platform.machine()
282
+ is_apple = arch == "arm64" and platform.system() == "Darwin"
283
+ name = "Unknown CPU"
284
+ gen: Any = None
285
+
286
+ if is_apple:
287
+ profiler = _cmd(["system_profiler", "SPHardwareDataType"], timeout=8)
288
+ m = re.search(r"Chip:\s+(Apple M\S+)", profiler)
289
+ name = m.group(1) if m else "Apple Silicon"
290
+ gm = re.search(r"M(\d+)", name)
291
+ gen = int(gm.group(1)) if gm else 1
292
+ else:
293
+ brand = ""
294
+ if platform.system() == "Darwin":
295
+ brand = _cmd(["sysctl", "-n", "machdep.cpu.brand_string"])
296
+ elif platform.system() == "Windows":
297
+ raw = _cmd(["wmic", "cpu", "get", "Name", "/value"], timeout=5)
298
+ if "Name=" in raw:
299
+ brand = raw.split("Name=", 1)[-1].splitlines()[0].strip()
300
+ elif platform.system() == "Linux":
301
+ try:
302
+ for line in Path("/proc/cpuinfo").read_text(encoding="utf-8", errors="replace").splitlines():
303
+ if line.lower().startswith("model name"):
304
+ brand = line.split(":", 1)[-1].strip()
305
+ break
306
+ except Exception:
307
+ pass
308
+ name = brand or platform.processor() or "Unknown CPU"
309
+
310
+ return {"name": name, "arch": arch, "is_apple_silicon": is_apple, "gen": gen}
311
+
312
+
313
+ def _detect_cpu() -> Dict[str, Any]:
314
+ flags: List[str] = []
315
+ physical_cores = os.cpu_count() or 0
316
+ logical_cores = os.cpu_count() or 0
317
+ model = _detect_chip()["name"]
318
+ if platform.system() == "Darwin":
319
+ flags = [item.lower() for item in _cmd(["sysctl", "-n", "machdep.cpu.features"], timeout=5).split()]
320
+ try:
321
+ physical_cores = int(_cmd(["sysctl", "-n", "hw.physicalcpu"], timeout=5) or physical_cores)
322
+ logical_cores = int(_cmd(["sysctl", "-n", "hw.logicalcpu"], timeout=5) or logical_cores)
323
+ except ValueError:
324
+ pass
325
+ elif platform.system() == "Linux":
326
+ try:
327
+ text = Path("/proc/cpuinfo").read_text(encoding="utf-8", errors="replace")
328
+ for line in text.splitlines():
329
+ if line.lower().startswith(("flags", "features")):
330
+ flags = line.split(":", 1)[-1].strip().lower().split()
331
+ break
332
+ except Exception:
333
+ pass
334
+ elif platform.system() == "Windows":
335
+ raw = _cmd(["wmic", "cpu", "get", "Name,NumberOfCores,NumberOfLogicalProcessors", "/format:list"], timeout=5)
336
+ for line in raw.splitlines():
337
+ key, _, value = line.partition("=")
338
+ if key == "Name" and value.strip():
339
+ model = value.strip()
340
+ elif key == "NumberOfCores" and value.strip():
341
+ try:
342
+ physical_cores = int(value.strip())
343
+ except ValueError:
344
+ pass
345
+ elif key == "NumberOfLogicalProcessors" and value.strip():
346
+ try:
347
+ logical_cores = int(value.strip())
348
+ except ValueError:
349
+ pass
350
+ try:
351
+ import ctypes
352
+ kernel32 = ctypes.windll.kernel32
353
+ feature_map = {
354
+ 6: "sse",
355
+ 10: "sse2",
356
+ 13: "sse3",
357
+ 19: "neon",
358
+ 28: "rdrand",
359
+ }
360
+ flags.extend(name for code, name in feature_map.items() if kernel32.IsProcessorFeaturePresent(code))
361
+ except Exception:
362
+ pass
363
+ interesting = {"avx", "avx2", "avx512f", "fma", "neon", "sse4_2"}
364
+ if platform.system() == "Windows":
365
+ interesting.update({"sse", "sse2", "sse3", "rdrand"})
366
+ return {
367
+ "model": model,
368
+ "physical_cores": physical_cores,
369
+ "logical_cores": logical_cores,
370
+ "instructions": sorted({flag for flag in flags if flag in interesting}),
371
+ }
372
+
373
+ def _detect_ram_gb() -> float:
374
+ if platform.system() == "Windows":
375
+ raw = _cmd(["wmic", "ComputerSystem", "get", "TotalPhysicalMemory", "/format:list"], timeout=5)
376
+ for line in raw.splitlines():
377
+ if line.startswith("TotalPhysicalMemory="):
378
+ try:
379
+ return round(int(line.split("=", 1)[-1].strip()) / 1_073_741_824, 1)
380
+ except ValueError:
381
+ break
382
+ raw = _cmd(["sysctl", "-n", "hw.memsize"])
383
+ if raw:
384
+ try:
385
+ return round(int(raw) / 1_073_741_824, 1)
386
+ except ValueError:
387
+ pass
388
+ if platform.system() == "Darwin":
389
+ profiler = _cmd(["system_profiler", "SPHardwareDataType"], timeout=8)
390
+ m = re.search(r"Memory:\s+([\d.]+)\s*(TB|GB|MB)", profiler, re.IGNORECASE)
391
+ if m:
392
+ value = float(m.group(1))
393
+ unit = m.group(2).lower()
394
+ if unit == "tb":
395
+ return round(value * 1024, 1)
396
+ if unit == "gb":
397
+ return round(value, 1)
398
+ return round(value / 1024, 1)
399
+ hostinfo = _cmd(["hostinfo"], timeout=5)
400
+ m = re.search(r"Primary memory available:\s+([\d.]+)\s+gigabytes", hostinfo, re.IGNORECASE)
401
+ if m:
402
+ return round(float(m.group(1)), 1)
403
+ try:
404
+ with open("/proc/meminfo") as f:
405
+ for line in f:
406
+ if line.startswith("MemTotal:"):
407
+ return round(int(line.split()[1]) / 1_048_576, 1)
408
+ except Exception:
409
+ pass
410
+ return 0.0
411
+
412
+ def _detect_disk_free_gb() -> float:
413
+ try:
414
+ path = "C:\\" if platform.system() == "Windows" else "/"
415
+ return round(shutil.disk_usage(path).free / 1_073_741_824, 1)
416
+ except Exception:
417
+ return 0.0
418
+
419
+
420
+ def _detect_gpu() -> Dict[str, Any]:
421
+ devices: List[Dict[str, Any]] = []
422
+ nvidia_smi = _which_any("nvidia-smi")
423
+ if nvidia_smi:
424
+ info = _cmd([nvidia_smi, "--query-gpu=name,memory.total", "--format=csv,noheader,nounits"], timeout=8)
425
+ for line in [item.strip() for item in info.splitlines() if item.strip()]:
426
+ try:
427
+ name, mem = [part.strip() for part in line.split(",", 1)]
428
+ devices.append({"vendor": "nvidia", "name": name, "vram_mb": int(float(mem)), "backend": "cuda"})
429
+ except Exception:
430
+ continue
431
+
432
+ if platform.system() == "Windows":
433
+ shell = _which_any("powershell") or _which_any("pwsh")
434
+ raw = ""
435
+ if shell:
436
+ raw = _cmd([
437
+ shell, "-NoProfile", "-Command",
438
+ "Get-CimInstance Win32_VideoController | Select-Object Name,AdapterRAM | ConvertTo-Json -Compress",
439
+ ], timeout=8)
440
+ if not raw:
441
+ raw = _cmd(["wmic", "path", "win32_VideoController", "get", "Name,AdapterRAM", "/format:list"], timeout=8)
442
+ for item in _parse_windows_video_controllers(raw):
443
+ if any(existing.get("name") == item["name"] for existing in devices):
444
+ continue
445
+ low = item["name"].lower()
446
+ vendor, backend = "unknown", "cpu"
447
+ if "nvidia" in low or "geforce" in low or "rtx" in low:
448
+ vendor, backend = "nvidia", "cuda"
449
+ elif "amd" in low or "radeon" in low:
450
+ vendor, backend = "amd", "directml/vulkan"
451
+ elif "intel" in low or "arc" in low or "iris" in low:
452
+ vendor, backend = "intel", "directml/vulkan"
453
+ devices.append({"vendor": vendor, "name": item["name"], "vram_mb": item["vram_mb"], "backend": backend})
454
+ elif platform.system() == "Darwin":
455
+ sp = _cmd(["system_profiler", "SPDisplaysDataType"], timeout=8)
456
+ for line in sp.splitlines():
457
+ if "Chipset Model" in line:
458
+ devices.append({"vendor": "apple", "name": line.split(":", 1)[-1].strip(), "vram_mb": 0, "backend": "metal/mlx"})
459
+ break
460
+ elif platform.system() == "Linux" and not devices:
461
+ info = _cmd(["lspci"], timeout=5)
462
+ for line in info.splitlines():
463
+ low = line.lower()
464
+ if not any(token in low for token in ("vga", "3d controller", "display")):
465
+ continue
466
+ if "nvidia" in low:
467
+ devices.append({"vendor": "nvidia", "name": line.strip(), "vram_mb": 0, "backend": "cuda"})
468
+ elif "amd" in low or "advanced micro devices" in low or "radeon" in low:
469
+ devices.append({"vendor": "amd", "name": line.strip(), "vram_mb": 0, "backend": "rocm/vulkan"})
470
+ elif "intel" in low:
471
+ devices.append({"vendor": "intel", "name": line.strip(), "vram_mb": 0, "backend": "vulkan"})
472
+
473
+ primary = max(devices, key=lambda item: int(item.get("vram_mb") or 0), default={})
474
+ vram_mb = int(primary.get("vram_mb") or 0)
475
+ return {
476
+ "devices": devices,
477
+ "vendor": primary.get("vendor", "none"),
478
+ "name": primary.get("name", ""),
479
+ "vram_mb": vram_mb,
480
+ "vram_gb": round(vram_mb / 1024, 1),
481
+ "backend": primary.get("backend", "cpu"),
482
+ }
483
+
484
+
485
+ def _detect_cuda() -> Dict[str, Any]:
486
+ available, version, nvidia_smi, nvcc = detect_cuda(_which_any, lambda args: _cmd(args, timeout=5))
487
+ return {"available": available, "nvidia_smi": nvidia_smi, "nvcc": nvcc, "version": version}
488
+
489
+
490
+ def _detect_wsl() -> Dict[str, Any]:
491
+ raw = ""
492
+ try:
493
+ raw = Path("/proc/version").read_text(encoding="utf-8", errors="replace")
494
+ except Exception:
495
+ pass
496
+ is_wsl, version = detect_wsl_from_text(platform.system().lower(), raw)
497
+ return {"is_wsl": is_wsl, "version": version}
498
+
499
+
500
+ def _detect_tools() -> Dict[str, bool]:
501
+ repair_path_for()
502
+ detected = detect_tools(_which_any, ["brew", "ollama", "python3", "python", "node", "npm", "git", "tesseract", "lms", "nvidia-smi", "nvcc"])
503
+ return {tool: path is not None for tool, path in detected.items()}
504
+
505
+ def _detect_mlx() -> Dict[str, Any]:
506
+ return {
507
+ "available": _module_available("mlx"),
508
+ "mlx_vlm": _module_available("mlx_vlm"),
509
+ }
510
+
511
+ def _detect_api_keys() -> Dict[str, bool]:
512
+ return {
513
+ "openai": bool(os.getenv("OPENAI_API_KEY")),
514
+ "openrouter": bool(os.getenv("OPENROUTER_API_KEY")),
515
+ "groq": bool(os.getenv("GROQ_API_KEY")),
516
+ "together": bool(os.getenv("TOGETHER_API_KEY")),
517
+ }
518
+
519
+ def scan_environment() -> Dict[str, Any]:
520
+ chip = _detect_chip()
521
+ cpu = _detect_cpu()
522
+ gpu = _detect_gpu()
523
+ cuda = _detect_cuda()
524
+ wsl = _detect_wsl()
525
+ tools = _detect_tools()
526
+ python_binary = "python3" if tools.get("python3") else "python"
527
+ return {
528
+ "os": platform.system(),
529
+ "os_version": platform.mac_ver()[0] if platform.system() == "Darwin" else platform.version(),
530
+ "chip": chip,
531
+ "cpu": cpu,
532
+ "gpu": gpu,
533
+ "cuda": cuda,
534
+ "wsl": wsl,
535
+ "ram_gb": _detect_ram_gb(),
536
+ "disk_free_gb": _detect_disk_free_gb(),
537
+ "tools": tools,
538
+ "components": {
539
+ "homebrew": _component_detail("homebrew", "brew"),
540
+ "python": {**_component_detail("python", python_binary), "version": platform.python_version()},
541
+ "node": _component_detail("node", "node"),
542
+ "npm": _component_detail("node", "npm"),
543
+ "git": _component_detail("git", "git"),
544
+ "ollama": _component_detail("ollama", "ollama"),
545
+ "lmstudio": _component_detail("lmstudio", "lms"),
546
+ "cuda": {**_component_detail("cuda", "nvcc"), **cuda},
547
+ "tesseract": _component_detail("tesseract", "tesseract"),
548
+ "mlx": _component_detail("mlx", module="mlx"),
549
+ "mlx_vlm": _component_detail("mlx", module="mlx_vlm"),
550
+ },
551
+ "path": {
552
+ "active": os.environ.get("PATH", ""),
553
+ "extra": os.environ.get("LATTICEAI_EXTRA_PATH", ""),
554
+ },
555
+ "mlx": _detect_mlx(),
556
+ "api_keys": _detect_api_keys(),
557
+ }
558
+
559
+ # ── Model Catalog ─────────────────────────────────────────────────────────────
560
+ # (model_id, display_name, size_gb, tag, description, min_ram_gb)
561
+ _MODEL_CATALOG = [
562
+ ("mlx-community/Qwen3-VL-4B-Instruct-4bit", "Qwen3-VL 4B", 2.7, "VLM", "최신 Qwen 멀티모달 · 저사양", 4),
563
+ ("mlx-community/gemma-4-e2b-it-4bit", "Gemma 4 E2B", 3.6, "VLM", "Gemma 4 소형 멀티모달", 8),
564
+ ("mlx-community/Qwen3-VL-8B-Instruct-4bit", "Qwen3-VL 8B", 4.8, "VLM", "최신 Qwen 멀티모달 · 균형 추천", 16),
565
+ ("mlx-community/gemma-4-12b-it-4bit", "Gemma 4 12B", 7.6, "VLM", "Gemma 4 기본 추천 · 4bit", 16),
566
+ ("mlx-community/Llama-4-Scout-17B-16E-Instruct-4bit", "Llama 4 Scout", 11.8, "VLM", "Meta 최신 멀티모달 Scout", 24),
567
+ ("mlx-community/gemma-4-26b-a4b-it-4bit", "Gemma 4 26B", 15.6, "VLM", "이미지 지원 · 대형 추천", 32),
568
+ ("mlx-community/gemma-4-31b-it-4bit", "Gemma 4 31B", 18.4, "VLM+", "Gemma 4 최신 31B instruct", 48),
569
+ ("mlx-community/Qwen3-VL-30B-A3B-Instruct-4bit", "Qwen3-VL 30B A3B", 18.0, "VLM+", "최신 Qwen 대형 멀티모달", 48),
570
+ ]
571
+
572
+ _CROSS_PLATFORM_MODEL_CATALOG: Dict[str, List[Tuple[str, str, float, str, str, int]]] = {
573
+ "ollama": [
574
+ ("ollama:qwen3-vl:4b", "Qwen3-VL 4B", 2.7, "VLM", "Ollama 멀티모달 · 저사양", 4),
575
+ ("ollama:qwen3-vl:8b", "Qwen3-VL 8B", 4.8, "VLM", "Ollama 멀티모달 · 균형 추천", 16),
576
+ ("ollama:hf.co/ggml-org/gemma-4-12B-it-GGUF:Q4_K_M", "Gemma 4 12B Q4", 7.9, "VLM", "Hugging Face GGUF 기반 Gemma 4", 16),
577
+ ("ollama:hf.co/ggml-org/gemma-4-31B-it-GGUF:Q4_K_M", "Gemma 4 31B Q4", 18.7, "VLM+", "Hugging Face GGUF 기반 Gemma 4", 48),
578
+ ("ollama:hf.co/ggml-org/Llama-4-Scout-17B-16E-Instruct-GGUF:Q4_K_M", "Llama 4 Scout Q4", 12.0, "VLM", "Meta 최신 멀티모달 Scout", 24),
579
+ ],
580
+ "lmstudio": [
581
+ ("lmstudio:Qwen/Qwen3-VL-4B-Instruct", "Qwen3-VL 4B", 2.7, "VLM", "LM Studio 멀티모달 · 저사양", 4),
582
+ ("lmstudio:Qwen/Qwen3-VL-8B-Instruct", "Qwen3-VL 8B", 4.8, "VLM", "LM Studio 멀티모달 · 균형 추천", 16),
583
+ ("lmstudio:ggml-org/gemma-4-12B-it-GGUF", "Gemma 4 12B 4-bit", 7.9, "VLM", "LM Studio GGUF Gemma 4", 16),
584
+ ("lmstudio:ggml-org/gemma-4-31B-it-GGUF", "Gemma 4 31B 4-bit", 18.7, "VLM+", "LM Studio GGUF Gemma 4", 48),
585
+ ("lmstudio:Qwen/Qwen3-VL-30B-A3B-Instruct", "Qwen3-VL 30B A3B", 18.0, "VLM+", "대형 Qwen 멀티모달 · 24GB+ VRAM 권장", 32),
586
+ ("lmstudio:meta-llama/Llama-4-Scout-17B-16E-Instruct", "Llama 4 Scout", 12.0, "VLM", "Meta 최신 멀티모달 Scout", 24),
587
+ ],
588
+ "vllm": [
589
+ ("vllm:Qwen/Qwen3-VL-4B-Instruct", "Qwen3-VL 4B", 2.7, "VLM", "내 컴퓨터 GPU 실행 도구 권장", 4),
590
+ ("vllm:Qwen/Qwen3-VL-8B-Instruct", "Qwen3-VL 8B", 4.8, "VLM", "내 컴퓨터 NVIDIA 실행 도구 권장", 16),
591
+ ("vllm:google/gemma-4-12b-it", "Gemma 4 12B", 7.6, "VLM", "Gemma 4 기본 추천 · 4bit", 16),
592
+ ("vllm:Qwen/Qwen3-VL-30B-A3B-Instruct", "Qwen3-VL 30B A3B", 18.0, "VLM+", "대형 Qwen 멀티모달 · 24GB+ VRAM 권장", 32),
593
+ ("vllm:suitch/gemma-4-31B-it-4bit", "Gemma 4 31B", 18.7, "VLM+", "Gemma 4 최신 31B instruct", 48),
594
+ ("vllm:meta-llama/Llama-4-Scout-17B-16E-Instruct", "Llama 4 Scout", 12.0, "VLM", "Meta 최신 멀티모달 Scout", 24),
595
+ ],
596
+ "llamacpp": [
597
+ ("llamacpp:Qwen/Qwen3-VL-4B-Instruct-GGUF", "Qwen3-VL 4B GGUF", 2.7, "GGUF", "CPU/Vulkan 백업 · 멀티모달 GGUF", 4),
598
+ ("llamacpp:Qwen/Qwen3-VL-8B-Instruct-GGUF", "Qwen3-VL 8B GGUF", 4.8, "GGUF", "CPU/Vulkan 백업 · 균형형", 16),
599
+ ("llamacpp:ggml-org/gemma-4-12B-it-GGUF", "Gemma 4 12B GGUF", 7.9, "GGUF", "Gemma 4 12B Q4_K_M", 16),
600
+ ("llamacpp:ggml-org/gemma-4-31B-it-GGUF", "Gemma 4 31B GGUF", 18.7, "GGUF", "Gemma 4 31B Q4_K_M", 48),
601
+ ("llamacpp:ggml-org/Llama-4-Scout-17B-16E-Instruct-GGUF", "Llama 4 Scout GGUF", 12.0, "GGUF", "Meta 최신 멀티모달 Scout", 24),
602
+ ],
603
+ }
604
+
605
+ _VERSIONED_MODEL_PATTERNS = (
606
+ ("gemma", re.compile(r"\bgemma[-\s]?(\d+(?:\.\d+)?)", re.IGNORECASE)),
607
+ ("qwen", re.compile(r"\bqwen[-\s]?(\d+(?:\.\d+)?)", re.IGNORECASE)),
608
+ ("llama", re.compile(r"\bllama[-\s]?(\d+(?:\.\d+)?)", re.IGNORECASE)),
609
+ )
610
+
611
+ _BEST_MODEL_TIERS: Dict[str, List[Tuple[int, str]]] = {
612
+ "local_mlx": [
613
+ (48, "mlx-community/gemma-4-31b-it-4bit"),
614
+ (32, "mlx-community/gemma-4-26b-a4b-it-4bit"),
615
+ (16, "mlx-community/gemma-4-12b-it-4bit"),
616
+ (16, "mlx-community/Qwen3-VL-8B-Instruct-4bit"),
617
+ (4, "mlx-community/Qwen3-VL-4B-Instruct-4bit"),
618
+ ],
619
+ "ollama": [
620
+ (48, "ollama:hf.co/ggml-org/gemma-4-31B-it-GGUF:Q4_K_M"),
621
+ (16, "ollama:hf.co/ggml-org/gemma-4-12B-it-GGUF:Q4_K_M"),
622
+ (16, "ollama:qwen3-vl:8b"),
623
+ (4, "ollama:qwen3-vl:4b"),
624
+ ],
625
+ "lmstudio": [
626
+ (48, "lmstudio:ggml-org/gemma-4-31B-it-GGUF"),
627
+ (16, "lmstudio:ggml-org/gemma-4-12B-it-GGUF"),
628
+ (16, "lmstudio:Qwen/Qwen3-VL-8B-Instruct"),
629
+ (4, "lmstudio:Qwen/Qwen3-VL-4B-Instruct"),
630
+ ],
631
+ "vllm": [
632
+ (48, "vllm:suitch/gemma-4-31B-it-4bit"),
633
+ (16, "vllm:google/gemma-4-12b-it"),
634
+ (16, "vllm:Qwen/Qwen3-VL-8B-Instruct"),
635
+ (4, "vllm:Qwen/Qwen3-VL-4B-Instruct"),
636
+ ],
637
+ "llamacpp": [
638
+ (48, "llamacpp:ggml-org/gemma-4-31B-it-GGUF"),
639
+ (16, "llamacpp:ggml-org/gemma-4-12B-it-GGUF"),
640
+ (16, "llamacpp:Qwen/Qwen3-VL-8B-Instruct-GGUF"),
641
+ (4, "llamacpp:Qwen/Qwen3-VL-4B-Instruct-GGUF"),
642
+ ],
643
+ }
644
+
645
+
646
+ def _version_tuple(raw: str) -> Tuple[int, ...]:
647
+ return tuple(int(part) for part in raw.split(".") if part.isdigit())
648
+
649
+
650
+ def _catalog_row_family_version(row: Tuple[str, str, float, str, str, int]) -> Tuple[str, Tuple[int, ...]] | None:
651
+ text = f"{row[0]} {row[1]}"
652
+ for family, pattern in _VERSIONED_MODEL_PATTERNS:
653
+ match = pattern.search(text)
654
+ if match:
655
+ version = _version_tuple(match.group(1))
656
+ if version:
657
+ return family, version
658
+ return None
659
+
660
+
661
+ def _filter_lower_family_versions(
662
+ rows: List[Tuple[str, str, float, str, str, int]],
663
+ ) -> List[Tuple[str, str, float, str, str, int]]:
664
+ max_versions: Dict[str, Tuple[int, ...]] = {}
665
+ detected: List[Tuple[Tuple[str, str, float, str, str, int], Tuple[str, Tuple[int, ...]] | None]] = []
666
+ for row in rows:
667
+ version_info = _catalog_row_family_version(row)
668
+ detected.append((row, version_info))
669
+ if not version_info:
670
+ continue
671
+ family, version = version_info
672
+ if version > max_versions.get(family, (0,)):
673
+ max_versions[family] = version
674
+ return [
675
+ row for row, version_info in detected
676
+ if not version_info or version_info[1] >= max_versions.get(version_info[0], version_info[1])
677
+ ]
678
+
679
+
680
+ def _best_model_for_engine(engine: str, ram_gb: float, rows: List[Tuple[str, str, float, str, str, int]]) -> str:
681
+ available_ids = {row[0] for row in rows}
682
+ for min_ram, model_id in _BEST_MODEL_TIERS.get(engine, []):
683
+ if ram_gb >= min_ram and model_id in available_ids:
684
+ return model_id
685
+ return rows[0][0] if rows else ""
686
+
687
+
688
+ # ── Recommendation Logic ──────────────────────────────────────────────────────
689
+
690
+ def get_recommendations(env: Dict[str, Any]) -> Dict[str, Any]:
691
+ ram = env["ram_gb"]
692
+ chip = env["chip"]
693
+ mlx = env["mlx"]
694
+ tools = env["tools"]
695
+ api_keys = env["api_keys"]
696
+ disk_free = env["disk_free_gb"]
697
+ is_apple = chip["is_apple_silicon"]
698
+ gpu = env.get("gpu", {})
699
+ cuda = env.get("cuda", {})
700
+ wsl = env.get("wsl", {})
701
+ cpu = env.get("cpu", {})
702
+ os_name = env.get("os", "")
703
+
704
+ max_model_gb = ram * 0.72 # ~28% headroom for OS + apps
705
+
706
+ if is_apple:
707
+ preferred_engine = "local_mlx"
708
+ elif gpu.get("vendor") == "nvidia" and cuda.get("available") and (os_name == "Linux" or wsl.get("is_wsl")):
709
+ preferred_engine = "vllm"
710
+ elif tools.get("lms"):
711
+ preferred_engine = "lmstudio"
712
+ elif tools.get("ollama"):
713
+ preferred_engine = "ollama"
714
+ else:
715
+ preferred_engine = "llamacpp"
716
+
717
+ apple_catalog = _filter_lower_family_versions(_MODEL_CATALOG)
718
+ engine_catalog = (
719
+ []
720
+ if is_apple
721
+ else _filter_lower_family_versions(_CROSS_PLATFORM_MODEL_CATALOG[preferred_engine])
722
+ )
723
+ best_id = _best_model_for_engine(
724
+ "local_mlx" if is_apple else preferred_engine,
725
+ ram,
726
+ apple_catalog if is_apple else engine_catalog,
727
+ )
728
+
729
+ # ── Engines ──────────────────────────────────────────────────────────────
730
+ engines: List[Dict] = []
731
+
732
+ if is_apple:
733
+ if mlx["available"] and mlx["mlx_vlm"]:
734
+ engines.append({
735
+ "id": "engine_mlx", "name": "MLX",
736
+ "subtitle": f"{chip['name']} GPU 가속 · MLX-VLM 멀티모달 실행",
737
+ "status": "installed", "priority": "recommended",
738
+ "checked": True, "action": None, "badge": "설치됨",
739
+ })
740
+ else:
741
+ engines.append({
742
+ "id": "engine_mlx", "name": "MLX",
743
+ "subtitle": f"{chip['name']} 전용 MLX-VLM 멀티모달 실행",
744
+ "status": "available", "priority": "recommended",
745
+ "checked": True,
746
+ "action": {"type": "pip", "packages": ["mlx-vlm"], "verify_modules": ["mlx", "mlx_vlm"]},
747
+ "badge": "설치 필요",
748
+ })
749
+
750
+ if tools.get("ollama"):
751
+ engines.append({
752
+ "id": "engine_ollama", "name": "Ollama",
753
+ "subtitle": "범용 로컬 LLM 서버 · 크로스 플랫폼",
754
+ "status": "installed", "priority": "recommended" if preferred_engine == "ollama" else "optional",
755
+ "checked": preferred_engine == "ollama", "action": None, "badge": "설치됨",
756
+ })
757
+ else:
758
+ hint = "brew install 가능" if (tools.get("brew") or env["os"] == "Darwin") else "수동 설치 필요"
759
+ engines.append({
760
+ "id": "engine_ollama", "name": "Ollama",
761
+ "subtitle": "범용 로컬 LLM 서버 · 크로스 플랫폼",
762
+ "status": "available", "priority": "recommended" if preferred_engine == "ollama" else "optional",
763
+ "checked": preferred_engine == "ollama",
764
+ "action": (
765
+ {"type": "brew", "package": "ollama", "binary": "ollama", "official_url": OFFICIAL_DOWNLOADS["ollama"]}
766
+ if tools.get("brew")
767
+ else {"type": "url", "url": OFFICIAL_DOWNLOADS["ollama"], "binary": "ollama"}
768
+ ),
769
+ "badge": hint,
770
+ })
771
+
772
+ if not is_apple:
773
+ lmstudio_installed = bool(tools.get("lms"))
774
+ engines.append({
775
+ "id": "engine_lmstudio", "name": "LM Studio",
776
+ "subtitle": "Windows/macOS/Linux 데스크톱 GPU 서버 · 모델 다운로드 UI 포함",
777
+ "status": "installed" if lmstudio_installed else "available",
778
+ "priority": "recommended" if preferred_engine == "lmstudio" else "optional",
779
+ "checked": preferred_engine == "lmstudio",
780
+ "action": None if lmstudio_installed else {"type": "url", "url": OFFICIAL_DOWNLOADS["lmstudio"], "binary": "lms"},
781
+ "badge": "설치됨" if lmstudio_installed else "설치 필요",
782
+ })
783
+ if gpu.get("vendor") == "nvidia":
784
+ engines.append({
785
+ "id": "engine_cuda", "name": "CUDA",
786
+ "subtitle": f"NVIDIA {gpu.get('name') or 'GPU'} · VRAM {gpu.get('vram_gb') or 0} GB",
787
+ "status": "installed" if cuda.get("available") else "available",
788
+ "priority": "recommended",
789
+ "checked": False,
790
+ "action": None if cuda.get("available") else {"type": "url", "url": OFFICIAL_DOWNLOADS["cuda"], "binary": "nvcc"},
791
+ "badge": cuda.get("version") or ("감지됨" if cuda.get("available") else "설치 필요"),
792
+ })
793
+ engines.append({
794
+ "id": "engine_vllm", "name": "vLLM",
795
+ "subtitle": "NVIDIA 서버형 추론 · Windows는 WSL/Linux 권장",
796
+ "status": "available",
797
+ "priority": "recommended" if preferred_engine == "vllm" else "optional",
798
+ "checked": preferred_engine == "vllm",
799
+ "action": {"type": "pip", "packages": ["vllm", "huggingface_hub[cli]"], "verify_modules": ["vllm", "huggingface_hub"]},
800
+ "badge": "WSL/Linux 권장" if os_name == "Windows" and not wsl.get("is_wsl") else "설치 가능",
801
+ })
802
+ elif gpu.get("vendor") in {"amd", "intel"}:
803
+ engines.append({
804
+ "id": "engine_vulkan_directml", "name": "Vulkan/DirectML",
805
+ "subtitle": f"{gpu.get('vendor', '').upper()} GPU 감지 · LM Studio 또는 llama.cpp 백엔드 권장",
806
+ "status": "available",
807
+ "priority": "recommended" if preferred_engine in {"lmstudio", "llamacpp"} else "optional",
808
+ "checked": False,
809
+ "action": None,
810
+ "badge": gpu.get("backend") or "GPU",
811
+ })
812
+
813
+ components: List[Dict] = []
814
+ component_specs = [
815
+ ("homebrew", "Homebrew", "macOS 패키지 관리자 · 자동 설치 기반", "brew", None, "recommended"),
816
+ ("git", "Git", "저장소 · 확장 · MCP 도구 연동에 필요", "git", "git", "recommended"),
817
+ ("node", "Node.js", "npm 패키지와 VS Code 확장 개발에 필요", "node", "node", "optional"),
818
+ ("tesseract", "Tesseract OCR", "이미지/PDF OCR 기능에 필요", "tesseract", "tesseract", "optional"),
819
+ ]
820
+ for cid, name, subtitle, binary, brew_pkg, priority in component_specs:
821
+ installed = bool(tools.get(binary))
822
+ if cid == "homebrew" and env["os"] != "Darwin":
823
+ continue
824
+ if installed:
825
+ components.append({
826
+ "id": f"component_{cid}", "name": name,
827
+ "subtitle": subtitle, "status": "installed",
828
+ "priority": priority, "checked": False, "action": None,
829
+ "badge": "설치됨",
830
+ })
831
+ continue
832
+ if cid == "homebrew":
833
+ action = {"type": "url", "url": OFFICIAL_DOWNLOADS["homebrew"], "binary": "brew"}
834
+ elif tools.get("brew") and brew_pkg:
835
+ action = {"type": "brew", "package": brew_pkg, "binary": binary, "official_url": OFFICIAL_DOWNLOADS.get(cid)}
836
+ else:
837
+ action = {"type": "url", "url": OFFICIAL_DOWNLOADS.get(cid, ""), "binary": binary}
838
+ components.append({
839
+ "id": f"component_{cid}", "name": name,
840
+ "subtitle": subtitle, "status": "available",
841
+ "priority": priority, "checked": priority == "recommended",
842
+ "action": action, "badge": "설치 필요",
843
+ })
844
+
845
+ python_ok = sys.version_info >= (3, 11)
846
+ if not python_ok:
847
+ components.insert(0, {
848
+ "id": "component_python", "name": "Python 3.11+",
849
+ "subtitle": "Lattice AI 서버 실행에 필요한 Python 런타임",
850
+ "status": "available", "priority": "recommended", "checked": True,
851
+ "action": {"type": "url", "url": OFFICIAL_DOWNLOADS["python"], "binary": "python3"},
852
+ "badge": "업데이트 필요",
853
+ })
854
+
855
+ for provider, has_key in api_keys.items():
856
+ if has_key:
857
+ engines.append({
858
+ "id": f"engine_{provider}", "name": provider.title(),
859
+ "subtitle": f"{provider.upper()}_API_KEY 감지됨 · 클라우드 API",
860
+ "status": "ready", "priority": "optional",
861
+ "checked": False, "action": None, "badge": "준비됨",
862
+ })
863
+
864
+ # ── Models ───────────────────────────────────────────────────────────────
865
+ models: List[Dict] = []
866
+
867
+ if is_apple:
868
+ for mid, mname, size_gb, tag, desc, min_ram in apple_catalog:
869
+ fits = ram >= min_ram and size_gb <= max_model_gb and disk_free >= size_gb + 2
870
+ is_best = mid == best_id
871
+ models.append({
872
+ "id": f"model_{mid.replace('/', '__').replace('-', '_')}",
873
+ "model_id": mid,
874
+ "name": mname,
875
+ "subtitle": desc,
876
+ "size_gb": size_gb,
877
+ "tag": tag,
878
+ "fits": fits,
879
+ "priority": "recommended" if is_best else "optional",
880
+ "checked": is_best and fits,
881
+ "disabled": not fits,
882
+ "badge": f"{size_gb} GB",
883
+ "action": {"type": "load_model", "model_id": mid} if fits else None,
884
+ })
885
+ else:
886
+ vram_gb = float(gpu.get("vram_gb") or 0)
887
+ gpu_budget_gb = vram_gb * 1.15 if gpu.get("vendor") in {"nvidia", "amd", "intel"} and vram_gb else max_model_gb
888
+ model_budget_gb = min(max_model_gb, gpu_budget_gb)
889
+ for mid, mname, size_gb, tag, desc, min_ram in engine_catalog:
890
+ fits = ram >= min_ram and size_gb <= model_budget_gb and disk_free >= size_gb + 2
891
+ is_best = mid == best_id
892
+ models.append({
893
+ "id": f"model_{mid.replace('/', '__').replace(':', '__').replace('-', '_')}",
894
+ "model_id": mid,
895
+ "name": mname,
896
+ "subtitle": desc,
897
+ "size_gb": size_gb,
898
+ "tag": tag,
899
+ "fits": fits,
900
+ "priority": "recommended" if is_best else "optional",
901
+ "checked": is_best and fits,
902
+ "disabled": not fits,
903
+ "badge": f"{size_gb} GB · {preferred_engine}",
904
+ "action": {"type": "load_model", "model_id": mid} if fits else None,
905
+ })
906
+ if models and not any(item.get("checked") for item in models):
907
+ for item in models:
908
+ if not item.get("disabled"):
909
+ item["priority"] = "recommended"
910
+ item["checked"] = True
911
+ break
912
+
913
+ # ── MCPs ─────────────────────────────────────────────────────────────────
914
+ mcps: List[Dict] = [
915
+ {
916
+ "id": "mcp_files", "name": "Workspace Files",
917
+ "subtitle": "파일 읽기/쓰기 · 코드 생성 · 미리보기",
918
+ "status": "active", "priority": "recommended",
919
+ "checked": True, "action": None,
920
+ "badge": "기본 탑재", "needs_auth": False,
921
+ },
922
+ {
923
+ "id": "mcp_presentations", "name": "Presentations",
924
+ "subtitle": "PPTX · 슬라이드 자동 생성",
925
+ "status": "active", "priority": "optional",
926
+ "checked": False, "action": None,
927
+ "badge": "기본 탑재", "needs_auth": False,
928
+ },
929
+ {
930
+ "id": "mcp_github", "name": "GitHub",
931
+ "subtitle": "저장소 · PR · 이슈 · CI 연동",
932
+ "status": "available", "priority": "optional",
933
+ "checked": False,
934
+ "action": {"type": "auth", "url": "https://github.com/apps", "mcp_id": "github"},
935
+ "badge": "인증 필요", "needs_auth": True,
936
+ },
937
+ {
938
+ "id": "mcp_googledrive", "name": "Google Drive",
939
+ "subtitle": "Docs · Sheets · Drive 파일 연동",
940
+ "status": "available", "priority": "optional",
941
+ "checked": False,
942
+ "action": {"type": "auth", "url": "https://chatgpt.com/connectors", "mcp_id": "google-drive"},
943
+ "badge": "인증 필요", "needs_auth": True,
944
+ },
945
+ {
946
+ "id": "mcp_slack", "name": "Slack",
947
+ "subtitle": "팀 채널 공유 · 알림 워크플로",
948
+ "status": "available", "priority": "optional",
949
+ "checked": False,
950
+ "action": {"type": "auth", "url": "https://chatgpt.com/connectors", "mcp_id": "slack"},
951
+ "badge": "인증 필요", "needs_auth": True,
952
+ },
953
+ ]
954
+
955
+ return _hydrate_install_actions({
956
+ "components": components,
957
+ "engines": engines,
958
+ "models": models,
959
+ "mcps": mcps,
960
+ "summary": {
961
+ "chip": chip["name"],
962
+ "cpu_cores": cpu.get("logical_cores"),
963
+ "cpu_instructions": cpu.get("instructions", []),
964
+ "gpu": gpu.get("name") or gpu.get("vendor"),
965
+ "gpu_vendor": gpu.get("vendor"),
966
+ "vram_gb": gpu.get("vram_gb"),
967
+ "cuda": cuda.get("available"),
968
+ "cuda_version": cuda.get("version"),
969
+ "wsl": wsl,
970
+ "preferred_engine": preferred_engine,
971
+ "ram_gb": ram,
972
+ "disk_free_gb": disk_free,
973
+ "is_apple_silicon": is_apple,
974
+ "max_model_gb": round(max_model_gb, 1),
975
+ },
976
+ })
977
+
978
+ # ── Installation Stream ───────────────────────────────────────────────────────
979
+
980
+ def _verify_action(action: Dict[str, Any]) -> Tuple[bool, str]:
981
+ atype = action.get("type")
982
+ if atype == "pip":
983
+ modules = action.get("verify_modules") or [_package_module(pkg) for pkg in action.get("packages", [])]
984
+ missing = [module for module in modules if not _module_available(module)]
985
+ if missing:
986
+ return False, "Python 모듈 감지 실패: " + ", ".join(missing)
987
+ return True, "Python 모듈 import 테스트 통과"
988
+ binary = action.get("binary")
989
+ if binary:
990
+ return _verify_binary(binary)
991
+ return True, "검증 항목 없음"
992
+
993
+
994
+ async def _repair_action(
995
+ action: Dict[str, Any],
996
+ *,
997
+ confirmation_token: str | None = None,
998
+ actor: str | None = None,
999
+ ) -> Tuple[bool, str]:
1000
+ binary = action.get("binary")
1001
+ if binary:
1002
+ repair_path_for(binary)
1003
+ ok, msg = _verify_binary(binary)
1004
+ if ok:
1005
+ return True, f"PATH 자동 보정 완료: {msg}"
1006
+ if action.get("type") == "pip":
1007
+ packages = action.get("packages", [])
1008
+ if packages:
1009
+ if not _verify_action_confirmation(action, confirmation_token, name="repair_action"):
1010
+ return False, "설치 명령 확인 토큰이 일치하지 않습니다."
1011
+ for pkg in packages:
1012
+ success, err = await _pip_install(pkg, confirmed=True, actor=actor)
1013
+ if not success:
1014
+ return False, err
1015
+ return _verify_action(action)
1016
+ return False, "자동 복구 방법을 찾지 못했습니다."
1017
+
1018
+
1019
+ async def install_stream(
1020
+ items: List[Dict],
1021
+ router: Any,
1022
+ *,
1023
+ confirmation_token: str | None = None,
1024
+ user_email: str | None = None,
1025
+ ) -> AsyncIterator[str]:
1026
+ for item in items:
1027
+ item_id = item.get("id", "unknown")
1028
+ name = item.get("name", item_id)
1029
+ action = item.get("action") or {}
1030
+ atype = action.get("type")
1031
+
1032
+ if not atype:
1033
+ yield _sse({"id": item_id, "status": "skipped", "msg": f"{name} — 이미 준비됨"})
1034
+ await asyncio.sleep(0.04)
1035
+ continue
1036
+
1037
+ yield _sse({"id": item_id, "status": "starting", "msg": f"{name} 준비 중..."})
1038
+
1039
+ if atype == "pip":
1040
+ packages = action.get("packages", [])
1041
+ token = confirmation_token or action.get("confirmation_token") or (action.get("command_plan") or {}).get("confirmation_token")
1042
+ if not _verify_action_confirmation(action, token, name=str(item_id)):
1043
+ yield _sse({"id": item_id, "status": "error", "msg": "설치 명령 확인 토큰이 일치하지 않습니다."})
1044
+ continue
1045
+ ok = True
1046
+ for pkg in packages:
1047
+ yield _sse({"id": item_id, "status": "running", "msg": f"pip install {pkg} ..."})
1048
+ success, err = await _pip_install(pkg, confirmed=True, actor=user_email)
1049
+ if success:
1050
+ yield _sse({"id": item_id, "status": "progress", "msg": f"{pkg} 설치 완료"})
1051
+ else:
1052
+ yield _sse({"id": item_id, "status": "error", "msg": f"{pkg} 실패: {err[:400]}"})
1053
+ ok = False
1054
+ break
1055
+ if ok:
1056
+ yield _sse({"id": item_id, "status": "running", "msg": f"{name} 동작 테스트 중..."})
1057
+ verified, detail = _verify_action(action)
1058
+ if verified:
1059
+ yield _sse({"id": item_id, "status": "done", "msg": f"{name} 설치 · 검증 완료 ✅\n{detail}"})
1060
+ else:
1061
+ yield _sse({"id": item_id, "status": "running", "msg": f"검증 실패 — 자동 복구 중...\n{detail}"})
1062
+ repaired, repair_msg = await _repair_action(action, confirmation_token=token, actor=user_email)
1063
+ yield _sse({"id": item_id, "status": "done" if repaired else "error", "msg": repair_msg[:500]})
1064
+
1065
+ elif atype == "brew":
1066
+ pkg = action.get("package", "")
1067
+ token = confirmation_token or action.get("confirmation_token") or (action.get("command_plan") or {}).get("confirmation_token")
1068
+ if not _verify_action_confirmation(action, token, name=str(item_id)):
1069
+ yield _sse({"id": item_id, "status": "error", "msg": "설치 명령 확인 토큰이 일치하지 않습니다."})
1070
+ continue
1071
+ yield _sse({"id": item_id, "status": "running", "msg": f"brew install {pkg} ..."})
1072
+ success, err = await _brew_install(pkg, confirmed=True, actor=user_email)
1073
+ if success:
1074
+ yield _sse({"id": item_id, "status": "running", "msg": "설치 완료 감지 · PATH 보정 중..."})
1075
+ binary = action.get("binary")
1076
+ if binary:
1077
+ repair_path_for(binary)
1078
+ verified, detail = _verify_action(action)
1079
+ if verified:
1080
+ yield _sse({"id": item_id, "status": "done", "msg": f"{name} 설치 · 연결 · 검증 완료 ✅\n{detail}"})
1081
+ else:
1082
+ yield _sse({"id": item_id, "status": "running", "msg": f"검증 실패 — 자동 복구 중...\n{detail}"})
1083
+ repaired, repair_msg = await _repair_action(action, confirmation_token=token, actor=user_email)
1084
+ yield _sse({"id": item_id, "status": "done" if repaired else "error", "msg": repair_msg[:500]})
1085
+ else:
1086
+ url = action.get("official_url") or action.get("url")
1087
+ if url:
1088
+ yield _sse({"id": item_id, "status": "auth", "msg": f"자동 설치 실패 — 공식 다운로드 페이지를 엽니다.\n{err[:240]}", "auth_url": url})
1089
+ open_url(url)
1090
+ yield _sse({"id": item_id, "status": "error", "msg": f"실패: {err[:400]}"})
1091
+
1092
+ elif atype == "load_model":
1093
+ model_id = action.get("model_id", "")
1094
+ yield _sse({"id": item_id, "status": "running",
1095
+ "msg": f"모델 다운로드 · 로딩 중...\n{model_id}\n(용량에 따라 수 분 소요)"})
1096
+ try:
1097
+ await router.load_model(model_id)
1098
+ yield _sse({"id": item_id, "status": "done", "msg": f"{name} 로드 완료 ✅"})
1099
+ except Exception as e:
1100
+ yield _sse({"id": item_id, "status": "error", "msg": f"로드 실패: {str(e)[:400]}"})
1101
+
1102
+ elif atype == "auth":
1103
+ url = action.get("url", "")
1104
+ yield _sse({"id": item_id, "status": "auth",
1105
+ "msg": "브라우저에서 인증 페이지를 엽니다...", "auth_url": url})
1106
+ open_url(url)
1107
+ yield _sse({"id": item_id, "status": "waiting",
1108
+ "msg": "브라우저에서 인증 완료 후 계속하세요"})
1109
+
1110
+ elif atype == "url":
1111
+ url = action.get("url", "")
1112
+ yield _sse({"id": item_id, "status": "auth",
1113
+ "msg": "설치 페이지를 브라우저에서 엽니다...", "auth_url": url})
1114
+ open_url(url)
1115
+ binary = action.get("binary")
1116
+ if binary:
1117
+ yield _sse({"id": item_id, "status": "waiting",
1118
+ "msg": f"{binary} 설치 완료를 자동 감지하는 중입니다..."})
1119
+ ok, detail = await _wait_for_binary(binary)
1120
+ if ok:
1121
+ repair_path_for(binary)
1122
+ yield _sse({"id": item_id, "status": "done",
1123
+ "msg": f"{name} 설치 · PATH 연결 · 검증 완료 ✅\n{detail}"})
1124
+ else:
1125
+ yield _sse({"id": item_id, "status": "error",
1126
+ "msg": f"{detail}\n공식 페이지에서 설치 후 다시 시도하세요."})
1127
+ else:
1128
+ yield _sse({"id": item_id, "status": "waiting",
1129
+ "msg": "브라우저에서 설치 또는 인증을 완료한 뒤 다시 시도하세요"})
1130
+
1131
+ else:
1132
+ yield _sse({"id": item_id, "status": "error", "msg": f"알 수 없는 액션: {atype}"})
1133
+
1134
+ yield _sse({"status": "complete", "msg": "모든 항목 처리 완료!"})
1135
+
1136
+
1137
+ async def _pip_install(
1138
+ package: str,
1139
+ *,
1140
+ confirmation_token: str | None = None,
1141
+ confirmed: bool = False,
1142
+ actor: str | None = None,
1143
+ ) -> Tuple[bool, str]:
1144
+ command = [sys.executable, "-m", "pip", "install", "--upgrade", package]
1145
+ plan = command_plan(
1146
+ command,
1147
+ name=f"pip:{package}",
1148
+ purpose="setup_wizard_install",
1149
+ metadata={"package": package},
1150
+ )
1151
+ try:
1152
+ if not confirmed:
1153
+ require_command_confirmation(command, confirmation_token, purpose="setup_wizard_install")
1154
+ append_process_audit_event("setup_wizard_install", plan=plan, status="started", user_email=actor)
1155
+ proc = await asyncio.create_subprocess_exec(
1156
+ *command,
1157
+ stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE,
1158
+ )
1159
+ _, stderr = await asyncio.wait_for(proc.communicate(), timeout=600)
1160
+ stderr_text = stderr.decode(errors="replace")
1161
+ append_process_audit_event(
1162
+ "setup_wizard_install",
1163
+ plan=plan,
1164
+ status="finished",
1165
+ user_email=actor,
1166
+ returncode=proc.returncode,
1167
+ stderr=stderr_text,
1168
+ )
1169
+ if proc.returncode == 0:
1170
+ return True, ""
1171
+ return False, stderr_text
1172
+ except CommandConfirmationError as e:
1173
+ append_process_audit_event("setup_wizard_install", plan=plan, status="denied", user_email=actor, error=str(e))
1174
+ return False, str(e)
1175
+ except asyncio.TimeoutError:
1176
+ append_process_audit_event("setup_wizard_install", plan=plan, status="timeout", user_email=actor)
1177
+ return False, "설치 시간 초과 (10분)"
1178
+ except Exception as e:
1179
+ append_process_audit_event("setup_wizard_install", plan=plan, status="error", user_email=actor, error=str(e))
1180
+ return False, str(e)
1181
+
1182
+
1183
+ async def _brew_install(
1184
+ package: str,
1185
+ *,
1186
+ confirmation_token: str | None = None,
1187
+ confirmed: bool = False,
1188
+ actor: str | None = None,
1189
+ ) -> Tuple[bool, str]:
1190
+ brew = shutil.which("brew")
1191
+ if not brew:
1192
+ return False, "Homebrew 미설치 — https://brew.sh 에서 설치하세요"
1193
+ command = [brew, "install", package]
1194
+ plan = command_plan(
1195
+ command,
1196
+ name=f"brew:{package}",
1197
+ purpose="setup_wizard_install",
1198
+ metadata={"package": package},
1199
+ )
1200
+ try:
1201
+ if not confirmed:
1202
+ require_command_confirmation(command, confirmation_token, purpose="setup_wizard_install")
1203
+ append_process_audit_event("setup_wizard_install", plan=plan, status="started", user_email=actor)
1204
+ proc = await asyncio.create_subprocess_exec(
1205
+ *command,
1206
+ stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE,
1207
+ )
1208
+ _, stderr = await asyncio.wait_for(proc.communicate(), timeout=300)
1209
+ stderr_text = stderr.decode(errors="replace")
1210
+ append_process_audit_event(
1211
+ "setup_wizard_install",
1212
+ plan=plan,
1213
+ status="finished",
1214
+ user_email=actor,
1215
+ returncode=proc.returncode,
1216
+ stderr=stderr_text,
1217
+ )
1218
+ if proc.returncode == 0:
1219
+ return True, ""
1220
+ return False, stderr_text
1221
+ except CommandConfirmationError as e:
1222
+ append_process_audit_event("setup_wizard_install", plan=plan, status="denied", user_email=actor, error=str(e))
1223
+ return False, str(e)
1224
+ except asyncio.TimeoutError:
1225
+ append_process_audit_event("setup_wizard_install", plan=plan, status="timeout", user_email=actor)
1226
+ return False, "설치 시간 초과 (5분)"
1227
+ except Exception as e:
1228
+ append_process_audit_event("setup_wizard_install", plan=plan, status="error", user_email=actor, error=str(e))
1229
+ return False, str(e)
1230
+
1231
+
1232
+ def open_url(url: str) -> None:
1233
+ command: List[str]
1234
+ try:
1235
+ system = platform.system()
1236
+ if system == "Darwin":
1237
+ command = ["open", url]
1238
+ plan = command_plan(command, name="open_url", purpose="setup_wizard_open_url")
1239
+ append_process_audit_event("setup_wizard_open_url", plan=plan, status="started")
1240
+ subprocess.Popen(command)
1241
+ append_process_audit_event("setup_wizard_open_url", plan=plan, status="spawned")
1242
+ elif system == "Windows":
1243
+ command = ["os.startfile", url]
1244
+ plan = command_plan(command, name="open_url", purpose="setup_wizard_open_url")
1245
+ append_process_audit_event("setup_wizard_open_url", plan=plan, status="started")
1246
+ os.startfile(url) # type: ignore[attr-defined]
1247
+ append_process_audit_event("setup_wizard_open_url", plan=plan, status="spawned")
1248
+ else:
1249
+ command = ["xdg-open", url]
1250
+ plan = command_plan(command, name="open_url", purpose="setup_wizard_open_url")
1251
+ append_process_audit_event("setup_wizard_open_url", plan=plan, status="started")
1252
+ subprocess.Popen(command)
1253
+ append_process_audit_event("setup_wizard_open_url", plan=plan, status="spawned")
1254
+ except Exception as exc:
1255
+ try:
1256
+ append_process_audit_event(
1257
+ "setup_wizard_open_url",
1258
+ plan=command_plan(["open_url", url], name="open_url", purpose="setup_wizard_open_url"),
1259
+ status="error",
1260
+ error=str(exc),
1261
+ )
1262
+ except Exception:
1263
+ pass
1264
+ pass