@researai/deepscientist 1.5.0 → 1.5.2

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 (168) hide show
  1. package/AGENTS.md +26 -0
  2. package/README.md +47 -161
  3. package/assets/connectors/lingzhu/openclaw-bridge/README.md +124 -0
  4. package/assets/connectors/lingzhu/openclaw-bridge/index.ts +162 -0
  5. package/assets/connectors/lingzhu/openclaw-bridge/openclaw.plugin.json +145 -0
  6. package/assets/connectors/lingzhu/openclaw-bridge/package.json +35 -0
  7. package/assets/connectors/lingzhu/openclaw-bridge/src/cli.ts +180 -0
  8. package/assets/connectors/lingzhu/openclaw-bridge/src/config.ts +196 -0
  9. package/assets/connectors/lingzhu/openclaw-bridge/src/debug-log.ts +111 -0
  10. package/assets/connectors/lingzhu/openclaw-bridge/src/events.ts +4 -0
  11. package/assets/connectors/lingzhu/openclaw-bridge/src/http-handler.ts +1133 -0
  12. package/assets/connectors/lingzhu/openclaw-bridge/src/image-cache.ts +75 -0
  13. package/assets/connectors/lingzhu/openclaw-bridge/src/lingzhu-tools.ts +246 -0
  14. package/assets/connectors/lingzhu/openclaw-bridge/src/transform.ts +541 -0
  15. package/assets/connectors/lingzhu/openclaw-bridge/src/types.ts +131 -0
  16. package/assets/connectors/lingzhu/openclaw-bridge/tsconfig.json +14 -0
  17. package/assets/connectors/lingzhu/openclaw.lingzhu.config.template.json +39 -0
  18. package/bin/ds.js +2048 -166
  19. package/docs/en/00_QUICK_START.md +152 -0
  20. package/docs/en/01_SETTINGS_REFERENCE.md +1104 -0
  21. package/docs/en/02_START_RESEARCH_GUIDE.md +404 -0
  22. package/docs/en/03_QQ_CONNECTOR_GUIDE.md +325 -0
  23. package/docs/en/04_LINGZHU_CONNECTOR_GUIDE.md +216 -0
  24. package/docs/en/05_TUI_GUIDE.md +141 -0
  25. package/docs/en/06_RUNTIME_AND_CANVAS.md +679 -0
  26. package/docs/en/07_MEMORY_AND_MCP.md +253 -0
  27. package/docs/en/08_FIGURE_STYLE_GUIDE.md +97 -0
  28. package/docs/en/09_DOCTOR.md +152 -0
  29. package/docs/en/90_ARCHITECTURE.md +247 -0
  30. package/docs/en/91_DEVELOPMENT.md +195 -0
  31. package/docs/en/99_ACKNOWLEDGEMENTS.md +29 -0
  32. package/docs/zh/00_QUICK_START.md +152 -0
  33. package/docs/zh/01_SETTINGS_REFERENCE.md +1137 -0
  34. package/docs/zh/02_START_RESEARCH_GUIDE.md +414 -0
  35. package/docs/zh/03_QQ_CONNECTOR_GUIDE.md +324 -0
  36. package/docs/zh/04_LINGZHU_CONNECTOR_GUIDE.md +230 -0
  37. package/docs/zh/05_TUI_GUIDE.md +128 -0
  38. package/docs/zh/06_RUNTIME_AND_CANVAS.md +271 -0
  39. package/docs/zh/07_MEMORY_AND_MCP.md +235 -0
  40. package/docs/zh/08_FIGURE_STYLE_GUIDE.md +97 -0
  41. package/docs/zh/09_DOCTOR.md +154 -0
  42. package/docs/zh/99_ACKNOWLEDGEMENTS.md +29 -0
  43. package/install.sh +41 -16
  44. package/package.json +5 -2
  45. package/pyproject.toml +1 -1
  46. package/src/deepscientist/__init__.py +6 -1
  47. package/src/deepscientist/artifact/guidance.py +9 -2
  48. package/src/deepscientist/artifact/service.py +1026 -39
  49. package/src/deepscientist/bash_exec/monitor.py +27 -5
  50. package/src/deepscientist/bash_exec/runtime.py +639 -0
  51. package/src/deepscientist/bash_exec/service.py +99 -16
  52. package/src/deepscientist/bridges/base.py +3 -0
  53. package/src/deepscientist/bridges/connectors.py +292 -13
  54. package/src/deepscientist/channels/qq.py +19 -2
  55. package/src/deepscientist/channels/relay.py +1 -0
  56. package/src/deepscientist/cli.py +32 -25
  57. package/src/deepscientist/config/models.py +28 -2
  58. package/src/deepscientist/config/service.py +202 -7
  59. package/src/deepscientist/connector_runtime.py +2 -0
  60. package/src/deepscientist/daemon/api/handlers.py +68 -6
  61. package/src/deepscientist/daemon/api/router.py +3 -0
  62. package/src/deepscientist/daemon/app.py +531 -15
  63. package/src/deepscientist/doctor.py +511 -0
  64. package/src/deepscientist/gitops/diff.py +3 -0
  65. package/src/deepscientist/home.py +26 -2
  66. package/src/deepscientist/latex_runtime.py +17 -4
  67. package/src/deepscientist/lingzhu_support.py +182 -0
  68. package/src/deepscientist/mcp/context.py +3 -1
  69. package/src/deepscientist/mcp/server.py +55 -2
  70. package/src/deepscientist/prompts/builder.py +222 -58
  71. package/src/deepscientist/quest/layout.py +2 -0
  72. package/src/deepscientist/quest/service.py +133 -14
  73. package/src/deepscientist/quest/stage_views.py +65 -1
  74. package/src/deepscientist/runners/codex.py +2 -0
  75. package/src/deepscientist/runtime_tools/__init__.py +16 -0
  76. package/src/deepscientist/runtime_tools/builtins.py +19 -0
  77. package/src/deepscientist/runtime_tools/models.py +29 -0
  78. package/src/deepscientist/runtime_tools/registry.py +40 -0
  79. package/src/deepscientist/runtime_tools/service.py +59 -0
  80. package/src/deepscientist/runtime_tools/tinytex.py +25 -0
  81. package/src/deepscientist/shared.py +44 -17
  82. package/src/deepscientist/tinytex.py +276 -0
  83. package/src/prompts/connectors/lingzhu.md +15 -0
  84. package/src/prompts/connectors/qq.md +121 -0
  85. package/src/prompts/system.md +214 -37
  86. package/src/skills/analysis-campaign/SKILL.md +46 -7
  87. package/src/skills/baseline/SKILL.md +12 -5
  88. package/src/skills/decision/SKILL.md +7 -5
  89. package/src/skills/experiment/SKILL.md +22 -5
  90. package/src/skills/finalize/SKILL.md +9 -5
  91. package/src/skills/idea/SKILL.md +6 -5
  92. package/src/skills/intake-audit/SKILL.md +277 -0
  93. package/src/skills/intake-audit/references/state-audit-template.md +41 -0
  94. package/src/skills/rebuttal/SKILL.md +409 -0
  95. package/src/skills/rebuttal/references/action-plan-template.md +63 -0
  96. package/src/skills/rebuttal/references/evidence-update-template.md +30 -0
  97. package/src/skills/rebuttal/references/response-letter-template.md +113 -0
  98. package/src/skills/rebuttal/references/review-matrix-template.md +55 -0
  99. package/src/skills/review/SKILL.md +295 -0
  100. package/src/skills/review/references/experiment-todo-template.md +29 -0
  101. package/src/skills/review/references/review-report-template.md +83 -0
  102. package/src/skills/review/references/revision-log-template.md +40 -0
  103. package/src/skills/scout/SKILL.md +6 -5
  104. package/src/skills/write/SKILL.md +8 -4
  105. package/src/tui/dist/components/WelcomePanel.js +17 -43
  106. package/src/tui/dist/components/messages/BashExecOperationMessage.js +3 -2
  107. package/src/tui/package.json +1 -1
  108. package/src/ui/dist/assets/{AiManusChatView-7v-dHngU.js → AiManusChatView-CZpg376x.js} +127 -597
  109. package/src/ui/dist/assets/{AnalysisPlugin-B_Xmz-KE.js → AnalysisPlugin-CtHA22g3.js} +1 -1
  110. package/src/ui/dist/assets/{AutoFigurePlugin-Cko-0tm1.js → AutoFigurePlugin-BSWmLMmF.js} +63 -8
  111. package/src/ui/dist/assets/{CliPlugin-BsU0ht7q.js → CliPlugin-CJ7jdm_s.js} +43 -609
  112. package/src/ui/dist/assets/{CodeEditorPlugin-DcMMP0Rt.js → CodeEditorPlugin-DhInVGFf.js} +8 -8
  113. package/src/ui/dist/assets/{CodeViewerPlugin-BqoQ5QyY.js → CodeViewerPlugin-D1n8S9r5.js} +5 -5
  114. package/src/ui/dist/assets/{DocViewerPlugin-D7eHNhU6.js → DocViewerPlugin-C4XM_kqk.js} +3 -3
  115. package/src/ui/dist/assets/{GitDiffViewerPlugin-DLJN42T5.js → GitDiffViewerPlugin-W6kS9r6v.js} +1 -1
  116. package/src/ui/dist/assets/{ImageViewerPlugin-gJMV7MOu.js → ImageViewerPlugin-DPeUx_Oz.js} +5 -6
  117. package/src/ui/dist/assets/{LabCopilotPanel-B857sfxP.js → LabCopilotPanel-eAelUaub.js} +12 -15
  118. package/src/ui/dist/assets/LabPlugin-BbOrBxKY.js +2676 -0
  119. package/src/ui/dist/assets/{LatexPlugin-DWKEo-Wj.js → LatexPlugin-C-HhkVXY.js} +16 -16
  120. package/src/ui/dist/assets/{MarkdownViewerPlugin-DBzoEmhv.js → MarkdownViewerPlugin-BDIzIBfh.js} +4 -4
  121. package/src/ui/dist/assets/{MarketplacePlugin-DoHc-8vo.js → MarketplacePlugin-DAOJphwr.js} +3 -3
  122. package/src/ui/dist/assets/{NotebookEditor-CKjKH-yS.js → NotebookEditor-BsoMvDoU.js} +3 -3
  123. package/src/ui/dist/assets/{PdfLoader-zFoL0VPo.js → PdfLoader-fiC7RtHf.js} +1 -1
  124. package/src/ui/dist/assets/{PdfMarkdownPlugin-DXPaL9Nt.js → PdfMarkdownPlugin-C5OxZBFK.js} +3 -3
  125. package/src/ui/dist/assets/{PdfViewerPlugin-DhK8qCFp.js → PdfViewerPlugin-CAbxQebk.js} +10 -10
  126. package/src/ui/dist/assets/{SearchPlugin-CdSi6krf.js → SearchPlugin-SE33Lb9B.js} +1 -1
  127. package/src/ui/dist/assets/{Stepper-V-WiDQJl.js → Stepper-0Av7GfV7.js} +1 -1
  128. package/src/ui/dist/assets/{TextViewerPlugin-hIs1Efiu.js → TextViewerPlugin-Daf2gJDI.js} +4 -4
  129. package/src/ui/dist/assets/{VNCViewer-DG8b0q2X.js → VNCViewer-BKrMUIOX.js} +9 -10
  130. package/src/ui/dist/assets/{bibtex-HDac6fVW.js → bibtex-JBdOEe45.js} +1 -1
  131. package/src/ui/dist/assets/{code-BnBeNxBc.js → code-B0TDFCZz.js} +1 -1
  132. package/src/ui/dist/assets/{file-content-IRQ3jHb8.js → file-content-3YtrSacz.js} +1 -1
  133. package/src/ui/dist/assets/{file-diff-panel-DZoQ9I6r.js → file-diff-panel-CJEg5OG1.js} +1 -1
  134. package/src/ui/dist/assets/{file-socket-BMCdLc-P.js → file-socket-CYQYdmB1.js} +1 -1
  135. package/src/ui/dist/assets/{file-utils-CltILB3w.js → file-utils-Cd1C9Ppl.js} +1 -1
  136. package/src/ui/dist/assets/{image-Boe6ffhu.js → image-B33ctrvC.js} +1 -1
  137. package/src/ui/dist/assets/{index-2Zf65FZt.js → index-9CLPVeZh.js} +1 -1
  138. package/src/ui/dist/assets/{index-DZqJ-qAM.js → index-BNQWqmJ2.js} +60 -2154
  139. package/src/ui/dist/assets/{index-DO43pFZP.js → index-BVXsmS7V.js} +84086 -84365
  140. package/src/ui/dist/assets/{index-BlplpvE1.js → index-Buw_N1VQ.js} +2 -2
  141. package/src/ui/dist/assets/{index-Bq2bvfkl.css → index-SwmFAld3.css} +2622 -2619
  142. package/src/ui/dist/assets/{message-square-mUHn_Ssb.js → message-square-D0cUJ9yU.js} +1 -1
  143. package/src/ui/dist/assets/{monaco-fe0arNEU.js → monaco-UZLYkp2n.js} +1 -1
  144. package/src/ui/dist/assets/{popover-D_7i19qU.js → popover-CTeiY-dK.js} +1 -1
  145. package/src/ui/dist/assets/{project-sync-DyVGrU7H.js → project-sync-Dbs01Xky.js} +2 -8
  146. package/src/ui/dist/assets/{sigma-BzazRyxQ.js → sigma-CM08S-xT.js} +1 -1
  147. package/src/ui/dist/assets/{tooltip-DN_yjHFH.js → tooltip-pDtzvU9p.js} +1 -1
  148. package/src/ui/dist/assets/trash-YvPCP-da.js +32 -0
  149. package/src/ui/dist/assets/{useCliAccess-DV2L2Qxy.js → useCliAccess-Bavi74Ac.js} +12 -42
  150. package/src/ui/dist/assets/{useFileDiffOverlay-DyTj-p_V.js → useFileDiffOverlay-CVXY6oeg.js} +1 -1
  151. package/src/ui/dist/assets/{wrap-text-ozYHtUwq.js → wrap-text-Cf4flRW7.js} +1 -1
  152. package/src/ui/dist/assets/{zoom-out-BN9MUyCQ.js → zoom-out-Hb0Z1YpT.js} +1 -1
  153. package/src/ui/dist/index.html +2 -2
  154. package/uv.lock +1155 -0
  155. package/assets/fonts/Inter-Variable.ttf +0 -0
  156. package/assets/fonts/NotoSerifSC-Regular-C94HN_ZN.ttf +0 -0
  157. package/assets/fonts/NunitoSans-Variable.ttf +0 -0
  158. package/assets/fonts/Satoshi-Medium-ByP-Zb-9.woff2 +0 -0
  159. package/assets/fonts/SourceSans3-Variable.ttf +0 -0
  160. package/assets/fonts/ds-fonts.css +0 -83
  161. package/src/ui/dist/assets/Inter-Variable-VF2RPR_K.ttf +0 -0
  162. package/src/ui/dist/assets/LabPlugin-bL7rpic8.js +0 -43
  163. package/src/ui/dist/assets/NotoSerifSC-Regular-C94HN_ZN-C94HN_ZN.ttf +0 -0
  164. package/src/ui/dist/assets/NunitoSans-Variable-B_ZymHAd.ttf +0 -0
  165. package/src/ui/dist/assets/Satoshi-Medium-ByP-Zb-9-GkA34YXu.woff2 +0 -0
  166. package/src/ui/dist/assets/SourceSans3-Variable-CD-WOsSK.ttf +0 -0
  167. package/src/ui/dist/assets/info-CcsK_htA.js +0 -18
  168. package/src/ui/dist/assets/user-plus-BusDx-hF.js +0 -79
package/bin/ds.js CHANGED
@@ -3,13 +3,16 @@ const crypto = require('node:crypto');
3
3
  const fs = require('node:fs');
4
4
  const os = require('node:os');
5
5
  const path = require('node:path');
6
+ const readline = require('node:readline');
6
7
  const { pathToFileURL } = require('node:url');
7
8
  const { spawn, spawnSync } = require('node:child_process');
8
9
 
9
10
  const repoRoot = path.resolve(__dirname, '..');
10
- const srcPath = path.join(repoRoot, 'src');
11
11
  const packageJson = JSON.parse(fs.readFileSync(path.join(repoRoot, 'package.json'), 'utf8'));
12
+ const pyprojectToml = fs.readFileSync(path.join(repoRoot, 'pyproject.toml'), 'utf8');
12
13
  const pythonCandidates = process.platform === 'win32' ? ['python', 'py'] : ['python3', 'python'];
14
+ const requiredPythonSpec = parseRequiredPythonSpec(pyprojectToml);
15
+ const minimumPythonVersion = parseMinimumPythonVersion(requiredPythonSpec);
13
16
  const pythonCommands = new Set([
14
17
  'init',
15
18
  'new',
@@ -21,12 +24,17 @@ const pythonCommands = new Set([
21
24
  'note',
22
25
  'approve',
23
26
  'graph',
24
- 'metrics',
27
+ 'doctor',
28
+ 'docker',
25
29
  'push',
26
30
  'memory',
27
31
  'baseline',
32
+ 'latex',
28
33
  'config',
29
34
  ]);
35
+ const UPDATE_PACKAGE_NAME = String(packageJson.name || '@researai/deepscientist').trim() || '@researai/deepscientist';
36
+ const UPDATE_CHECK_TTL_MS = 12 * 60 * 60 * 1000;
37
+ const UPDATE_PROMPT_TTL_MS = 12 * 60 * 60 * 1000;
30
38
 
31
39
  const optionsWithValues = new Set(['--home', '--host', '--port', '--quest-id', '--mode']);
32
40
 
@@ -34,17 +42,50 @@ function printLauncherHelp() {
34
42
  console.log(`DeepScientist launcher
35
43
 
36
44
  Usage:
37
- research
38
- research --web
39
- research --both
40
- research --stop
41
- research --restart
42
- research --home ~/DeepScientist --port 20999
45
+ ds
46
+ ds update
47
+ ds update --check
48
+ ds update --yes
49
+ ds --tui
50
+ ds --both
51
+ ds --host 0.0.0.0 --port 21000
52
+ ds --stop
53
+ ds --restart
54
+ ds --status
55
+ ds doctor
56
+ ds latex status
57
+ ds --home ~/DeepScientist --port 20999
58
+
59
+ Launcher flags:
60
+ --host <host> Bind host for the local web daemon
61
+ --port <port> Bind port for the local web daemon
62
+ --tui Start the terminal workspace only
63
+ --both Start web + terminal workspace together
64
+ --no-browser Do not auto-open the browser
65
+ --daemon-only Start the managed daemon and exit
66
+ --status Print managed daemon health as JSON
67
+ --stop Stop the managed daemon
68
+ --restart Restart the managed daemon
69
+ --home <path> Use a custom DeepScientist home
70
+ --quest-id <id> Open the TUI on one quest directly
71
+
72
+ Update:
73
+ ds update Check the npm package version and offer update actions
74
+ ds update --check Print structured update status
75
+ ds update --yes Install the latest npm release immediately
76
+
77
+ Runtime:
78
+ DeepScientist uses uv to manage a locked local Python runtime.
79
+ If uv is missing, ds bootstraps a local copy under the DeepScientist home automatically.
80
+ If an active conda environment provides Python ${requiredPythonSpec}, ds prefers it.
81
+ Otherwise uv provisions a managed Python under the DeepScientist home automatically.
43
82
 
44
83
  Advanced Python CLI:
45
84
  ds init
46
- ds new "reproduce baseline and test idea"
47
- ds run decision --quest-id q-001 --message "review current state"
85
+ ds new "reproduce baseline and test one stronger idea"
86
+ ds doctor
87
+ ds latex install-runtime
88
+ ds run decision --quest-id 001 --message "review current state"
48
89
  `);
49
90
  }
50
91
 
@@ -52,6 +93,292 @@ function ensureDir(targetPath) {
52
93
  fs.mkdirSync(targetPath, { recursive: true });
53
94
  }
54
95
 
96
+ function expandUserPath(rawPath) {
97
+ const normalized = String(rawPath || '').trim();
98
+ if (!normalized) {
99
+ return normalized;
100
+ }
101
+ if (normalized === '~') {
102
+ return os.homedir();
103
+ }
104
+ if (normalized.startsWith(`~${path.sep}`) || normalized.startsWith('~/')) {
105
+ return path.join(os.homedir(), normalized.slice(2));
106
+ }
107
+ return normalized;
108
+ }
109
+
110
+ function updateStatePath(home) {
111
+ return path.join(home, 'runtime', 'update-state.json');
112
+ }
113
+
114
+ function readUpdateState(home) {
115
+ return readJsonFile(updateStatePath(home)) || {};
116
+ }
117
+
118
+ function writeUpdateState(home, payload) {
119
+ const statePath = updateStatePath(home);
120
+ ensureDir(path.dirname(statePath));
121
+ fs.writeFileSync(statePath, `${JSON.stringify(payload, null, 2)}\n`, 'utf8');
122
+ }
123
+
124
+ function mergeUpdateState(home, patch) {
125
+ const current = readUpdateState(home);
126
+ const next = {
127
+ ...current,
128
+ ...patch,
129
+ };
130
+ writeUpdateState(home, next);
131
+ return next;
132
+ }
133
+
134
+ function parseTimestamp(value) {
135
+ if (!value) {
136
+ return null;
137
+ }
138
+ const parsed = Date.parse(String(value));
139
+ return Number.isFinite(parsed) ? parsed : null;
140
+ }
141
+
142
+ function isExpired(value, ttlMs) {
143
+ const parsed = parseTimestamp(value);
144
+ if (parsed === null) {
145
+ return true;
146
+ }
147
+ return Date.now() - parsed > ttlMs;
148
+ }
149
+
150
+ function normalizeVersion(value) {
151
+ return String(value || '')
152
+ .trim()
153
+ .replace(/^v/i, '');
154
+ }
155
+
156
+ function compareVersions(left, right) {
157
+ const leftParts = normalizeVersion(left).split('.').map((item) => Number.parseInt(item, 10) || 0);
158
+ const rightParts = normalizeVersion(right).split('.').map((item) => Number.parseInt(item, 10) || 0);
159
+ const length = Math.max(leftParts.length, rightParts.length, 3);
160
+ for (let index = 0; index < length; index += 1) {
161
+ const leftValue = leftParts[index] || 0;
162
+ const rightValue = rightParts[index] || 0;
163
+ if (leftValue > rightValue) {
164
+ return 1;
165
+ }
166
+ if (leftValue < rightValue) {
167
+ return -1;
168
+ }
169
+ }
170
+ return 0;
171
+ }
172
+
173
+ function detectInstallMode(rootPath = repoRoot) {
174
+ const normalized = String(rootPath || '');
175
+ return normalized.includes(`${path.sep}node_modules${path.sep}`) ? 'npm-package' : 'source-checkout';
176
+ }
177
+
178
+ function updateManualCommand(installMode) {
179
+ if (installMode === 'npm-package') {
180
+ return `npm install -g ${UPDATE_PACKAGE_NAME}@latest`;
181
+ }
182
+ return 'git pull && bash install.sh';
183
+ }
184
+
185
+ function updateSupportSummary(installMode, npmBinary, launcherPath) {
186
+ if (!npmBinary) {
187
+ return {
188
+ canCheck: false,
189
+ canSelfUpdate: false,
190
+ reason: '`npm` is not available on PATH.',
191
+ };
192
+ }
193
+ if (installMode !== 'npm-package') {
194
+ return {
195
+ canCheck: true,
196
+ canSelfUpdate: false,
197
+ reason: 'This DeepScientist installation comes from a source checkout and should be updated from Git.',
198
+ };
199
+ }
200
+ if (!launcherPath || !fs.existsSync(launcherPath)) {
201
+ return {
202
+ canCheck: true,
203
+ canSelfUpdate: false,
204
+ reason: 'The launcher entrypoint could not be resolved.',
205
+ };
206
+ }
207
+ return {
208
+ canCheck: true,
209
+ canSelfUpdate: true,
210
+ reason: null,
211
+ };
212
+ }
213
+
214
+ function resolveNpmBinary() {
215
+ return resolveExecutableOnPath(process.platform === 'win32' ? 'npm.cmd' : 'npm') || resolveExecutableOnPath('npm');
216
+ }
217
+
218
+ function resolveLauncherPath() {
219
+ const configured = String(process.env.DEEPSCIENTIST_LAUNCHER_PATH || '').trim();
220
+ if (configured && fs.existsSync(configured)) {
221
+ return configured;
222
+ }
223
+ const candidate = path.join(repoRoot, 'bin', 'ds.js');
224
+ return fs.existsSync(candidate) ? candidate : null;
225
+ }
226
+
227
+ function fetchLatestPublishedVersion({ npmBinary, timeoutMs = 3500 }) {
228
+ if (!npmBinary) {
229
+ return {
230
+ ok: false,
231
+ error: '`npm` is not available on PATH.',
232
+ latestVersion: null,
233
+ };
234
+ }
235
+ const result = spawnSync(npmBinary, ['view', UPDATE_PACKAGE_NAME, 'version', '--json'], {
236
+ encoding: 'utf8',
237
+ env: process.env,
238
+ timeout: timeoutMs,
239
+ });
240
+ if (result.error) {
241
+ return {
242
+ ok: false,
243
+ error: result.error.message,
244
+ latestVersion: null,
245
+ };
246
+ }
247
+ if (result.status !== 0) {
248
+ return {
249
+ ok: false,
250
+ error: (result.stderr || result.stdout || '').trim() || `npm exited with status ${result.status}`,
251
+ latestVersion: null,
252
+ };
253
+ }
254
+ try {
255
+ const parsed = JSON.parse(String(result.stdout || 'null'));
256
+ const latestVersion = Array.isArray(parsed) ? normalizeVersion(parsed[parsed.length - 1]) : normalizeVersion(parsed);
257
+ if (!latestVersion) {
258
+ throw new Error('npm returned an empty version string.');
259
+ }
260
+ return {
261
+ ok: true,
262
+ error: null,
263
+ latestVersion,
264
+ };
265
+ } catch (error) {
266
+ return {
267
+ ok: false,
268
+ error: error instanceof Error ? error.message : 'Could not parse npm version output.',
269
+ latestVersion: null,
270
+ };
271
+ }
272
+ }
273
+
274
+ function buildUpdateStatus(home, statePatch = {}) {
275
+ const state = { ...readUpdateState(home), ...statePatch };
276
+ const installMode = detectInstallMode(repoRoot);
277
+ const npmBinary = resolveNpmBinary();
278
+ const launcherPath = resolveLauncherPath();
279
+ const support = updateSupportSummary(installMode, npmBinary, launcherPath);
280
+ const currentVersion = normalizeVersion(state.current_version || packageJson.version);
281
+ const latestVersion = normalizeVersion(state.latest_version || '');
282
+ const updateAvailable = Boolean(latestVersion) && compareVersions(latestVersion, currentVersion) > 0;
283
+ const skippedVersion = normalizeVersion(state.last_skipped_version || '');
284
+ const skippedCurrentTarget = Boolean(updateAvailable && skippedVersion && skippedVersion === latestVersion);
285
+ const promptRecommended =
286
+ Boolean(updateAvailable)
287
+ && !Boolean(state.busy)
288
+ && !skippedCurrentTarget
289
+ && isExpired(state.last_prompted_at || state.last_deferred_at, UPDATE_PROMPT_TTL_MS);
290
+
291
+ return {
292
+ ok: true,
293
+ package_name: UPDATE_PACKAGE_NAME,
294
+ install_mode: installMode,
295
+ can_check: support.canCheck,
296
+ can_self_update: support.canSelfUpdate,
297
+ current_version: currentVersion,
298
+ latest_version: latestVersion || null,
299
+ update_available: updateAvailable,
300
+ prompt_recommended: promptRecommended,
301
+ busy: Boolean(state.busy),
302
+ last_checked_at: state.last_checked_at || null,
303
+ last_check_error: state.last_check_error || null,
304
+ last_prompted_at: state.last_prompted_at || null,
305
+ last_deferred_at: state.last_deferred_at || null,
306
+ last_skipped_version: skippedVersion || null,
307
+ last_update_started_at: state.last_update_started_at || null,
308
+ last_update_finished_at: state.last_update_finished_at || null,
309
+ last_update_result: state.last_update_result || null,
310
+ target_version: normalizeVersion(state.target_version || '') || null,
311
+ manual_update_command: updateManualCommand(installMode),
312
+ reason: support.reason,
313
+ };
314
+ }
315
+
316
+ function checkForUpdates(home, { force = false, timeoutMs = 3500 } = {}) {
317
+ const currentVersion = normalizeVersion(packageJson.version);
318
+ const existing = readUpdateState(home);
319
+ const installMode = detectInstallMode(repoRoot);
320
+ const npmBinary = resolveNpmBinary();
321
+ const launcherPath = resolveLauncherPath();
322
+ const support = updateSupportSummary(installMode, npmBinary, launcherPath);
323
+
324
+ if (!force && existing.current_version === currentVersion && !isExpired(existing.last_checked_at, UPDATE_CHECK_TTL_MS)) {
325
+ return buildUpdateStatus(home);
326
+ }
327
+
328
+ if (!support.canCheck) {
329
+ const patched = mergeUpdateState(home, {
330
+ current_version: currentVersion,
331
+ last_checked_at: new Date().toISOString(),
332
+ last_check_error: support.reason,
333
+ });
334
+ return buildUpdateStatus(home, patched);
335
+ }
336
+
337
+ const probe = fetchLatestPublishedVersion({ npmBinary, timeoutMs });
338
+ const patched = mergeUpdateState(home, {
339
+ current_version: currentVersion,
340
+ latest_version: probe.latestVersion || existing.latest_version || null,
341
+ last_checked_at: new Date().toISOString(),
342
+ last_check_error: probe.ok ? null : probe.error,
343
+ });
344
+ return buildUpdateStatus(home, patched);
345
+ }
346
+
347
+ function markUpdateDeferred(home, version) {
348
+ const patched = mergeUpdateState(home, {
349
+ last_prompted_at: new Date().toISOString(),
350
+ last_deferred_at: new Date().toISOString(),
351
+ latest_version: normalizeVersion(version || readUpdateState(home).latest_version || '') || null,
352
+ });
353
+ return buildUpdateStatus(home, patched);
354
+ }
355
+
356
+ function markUpdateSkipped(home, version) {
357
+ const normalized = normalizeVersion(version);
358
+ const patched = mergeUpdateState(home, {
359
+ last_prompted_at: new Date().toISOString(),
360
+ last_skipped_version: normalized || null,
361
+ });
362
+ return buildUpdateStatus(home, patched);
363
+ }
364
+
365
+ function parseRequiredPythonSpec(pyprojectText) {
366
+ const match = String(pyprojectText || '').match(/^\s*requires-python\s*=\s*["']([^"']+)["']/m);
367
+ return match ? match[1].trim() : '>=3.11';
368
+ }
369
+
370
+ function parseMinimumPythonVersion(spec) {
371
+ const match = String(spec || '').match(/>=\s*(\d+)\.(\d+)(?:\.(\d+))?/);
372
+ if (!match) {
373
+ return { major: 3, minor: 11, patch: 0 };
374
+ }
375
+ return {
376
+ major: Number(match[1]),
377
+ minor: Number(match[2]),
378
+ patch: Number(match[3] || 0),
379
+ };
380
+ }
381
+
55
382
  function resolveHome(args) {
56
383
  const index = args.indexOf('--home');
57
384
  if (index >= 0 && index + 1 < args.length) {
@@ -60,6 +387,9 @@ function resolveHome(args) {
60
387
  if (process.env.DEEPSCIENTIST_HOME) {
61
388
  return path.resolve(process.env.DEEPSCIENTIST_HOME);
62
389
  }
390
+ if (process.env.DS_HOME) {
391
+ return path.resolve(process.env.DS_HOME);
392
+ }
63
393
  return path.join(os.homedir(), 'DeepScientist');
64
394
  }
65
395
 
@@ -88,6 +418,222 @@ function bindUiUrl(host, port) {
88
418
  return `http://${formatHttpHost(normalized)}:${port}`;
89
419
  }
90
420
 
421
+ function normalizeMode(value) {
422
+ const normalized = String(value || '')
423
+ .trim()
424
+ .toLowerCase();
425
+ if (normalized === 'tui' || normalized === 'both' || normalized === 'web') {
426
+ return normalized;
427
+ }
428
+ return 'web';
429
+ }
430
+
431
+ function parseBooleanSetting(rawValue, fallback = false) {
432
+ if (typeof rawValue === 'boolean') {
433
+ return rawValue;
434
+ }
435
+ const normalized = String(rawValue || '')
436
+ .trim()
437
+ .toLowerCase();
438
+ if (['true', 'yes', 'on', '1'].includes(normalized)) {
439
+ return true;
440
+ }
441
+ if (['false', 'no', 'off', '0'].includes(normalized)) {
442
+ return false;
443
+ }
444
+ return fallback;
445
+ }
446
+
447
+ function supportsAnsi() {
448
+ return Boolean(process.stdout.isTTY && process.env.TERM !== 'dumb');
449
+ }
450
+
451
+ function stripAnsi(text) {
452
+ return String(text || '')
453
+ .replace(/\u001B]8;;[^\u0007]*\u0007/g, '')
454
+ .replace(/\u001B]8;;\u0007/g, '')
455
+ .replace(/\u001B\[[0-9;]*m/g, '');
456
+ }
457
+
458
+ function visibleWidth(text) {
459
+ return stripAnsi(text).length;
460
+ }
461
+
462
+ function centerText(text, width) {
463
+ const targetWidth = Math.max(visibleWidth(text), width || 0);
464
+ const padding = Math.max(0, Math.floor((targetWidth - visibleWidth(text)) / 2));
465
+ return `${' '.repeat(padding)}${text}`;
466
+ }
467
+
468
+ function hyperlink(url, label = url) {
469
+ if (!supportsAnsi()) {
470
+ return label;
471
+ }
472
+ return `\u001B]8;;${url}\u0007${label}\u001B]8;;\u0007`;
473
+ }
474
+
475
+ function colorize(code, text) {
476
+ if (!supportsAnsi()) {
477
+ return text;
478
+ }
479
+ return `${code}${text}\u001B[0m`;
480
+ }
481
+
482
+ function renderBrandArtwork() {
483
+ const brandPath = path.join(repoRoot, 'assets', 'branding', 'deepscientist-mark.png');
484
+ const chafa = resolveExecutableOnPath('chafa');
485
+ if (!supportsAnsi() || !chafa || !fs.existsSync(brandPath)) {
486
+ return [];
487
+ }
488
+ const width = Math.max(18, Math.min(30, Math.floor((process.stdout.columns || 100) / 3)));
489
+ const height = Math.max(8, Math.floor(width / 2));
490
+ try {
491
+ const result = spawnSync(
492
+ chafa,
493
+ ['--size', `${width}x${height}`, '--format', 'symbols', '--colors', '16', brandPath],
494
+ { encoding: 'utf8' }
495
+ );
496
+ if (result.status === 0 && result.stdout && result.stdout.trim()) {
497
+ return result.stdout.replace(/\s+$/, '').split(/\r?\n/);
498
+ }
499
+ } catch {}
500
+ return [];
501
+ }
502
+
503
+ function truncateMiddle(text, maxLength = 120) {
504
+ const value = String(text || '');
505
+ if (value.length <= maxLength) {
506
+ return value;
507
+ }
508
+ const head = Math.max(24, Math.floor((maxLength - 1) / 2));
509
+ const tail = Math.max(16, maxLength - head - 1);
510
+ return `${value.slice(0, head)}…${value.slice(-tail)}`;
511
+ }
512
+
513
+ function renderKeyValueRows(rows) {
514
+ const labelWidth = Math.max(...rows.map(([label]) => String(label).length), 8);
515
+ for (const [label, value] of rows) {
516
+ console.log(` ${String(label).padEnd(labelWidth)} ${value}`);
517
+ }
518
+ }
519
+
520
+ function pythonMajorMinor(probe) {
521
+ if (!probe || typeof probe.major !== 'number' || typeof probe.minor !== 'number') {
522
+ return '';
523
+ }
524
+ return `${probe.major}.${probe.minor}`;
525
+ }
526
+
527
+ function pythonVersionText(probe) {
528
+ if (!probe) {
529
+ return 'unknown';
530
+ }
531
+ const version = probe.version || pythonMajorMinor(probe) || 'unknown';
532
+ if (probe.executable) {
533
+ return `${version} (${probe.executable})`;
534
+ }
535
+ return version;
536
+ }
537
+
538
+ function renderLaunchHints({ home, url, bindUrl, pythonSelection }) {
539
+ const runtimeRows = [
540
+ ['Version', packageJson.version],
541
+ ['Home', truncateMiddle(home)],
542
+ ['Browser URL', url],
543
+ ['Bind URL', bindUrl],
544
+ ['Python', truncateMiddle(pythonVersionText(pythonSelection))],
545
+ ];
546
+ if (pythonSelection && pythonSelection.sourceLabel) {
547
+ runtimeRows.push(['Python source', pythonSelection.sourceLabel]);
548
+ }
549
+ console.log(colorize('\u001B[1;38;5;39m', 'Runtime'));
550
+ renderKeyValueRows(runtimeRows);
551
+ console.log('');
552
+
553
+ console.log(colorize('\u001B[1;38;5;39m', 'Quick Flags'));
554
+ renderKeyValueRows([
555
+ ['ds --port 21000', 'Change the web port'],
556
+ ['ds --host 0.0.0.0 --port 21000', 'Bind on all interfaces'],
557
+ ['ds --both', 'Start web + TUI together'],
558
+ ['ds --tui', 'Start the terminal workspace only'],
559
+ ['ds --no-browser', 'Do not auto-open the browser'],
560
+ ['ds --status', 'Show daemon health as JSON'],
561
+ ['ds --restart', 'Restart the managed daemon'],
562
+ ['ds --stop', 'Stop the managed daemon'],
563
+ ['ds --help', 'Show the full launcher help'],
564
+ ]);
565
+ console.log('');
566
+ }
567
+
568
+ function printLaunchCard({
569
+ url,
570
+ bindUrl,
571
+ mode,
572
+ autoOpenRequested,
573
+ browserOpened,
574
+ daemonOnly,
575
+ home,
576
+ pythonSelection,
577
+ }) {
578
+ const width = Math.max(72, Math.min(process.stdout.columns || 100, 108));
579
+ const divider = colorize('\u001B[38;5;245m', '─'.repeat(Math.max(36, width - 6)));
580
+ const title = colorize('\u001B[1;38;5;39m', 'ResearAI');
581
+ const subtitle = colorize('\u001B[38;5;110m', 'Local-first research operating system');
582
+ const versionLine = colorize('\u001B[38;5;245m', `Version ${packageJson.version}`);
583
+ const urlLabel = colorize('\u001B[1;38;5;45m', hyperlink(url, url));
584
+ const workspaceMode =
585
+ mode === 'both'
586
+ ? 'Web workspace + terminal workspace'
587
+ : mode === 'tui'
588
+ ? 'Terminal workspace'
589
+ : 'Web workspace';
590
+ const browserLine = autoOpenRequested
591
+ ? browserOpened
592
+ ? 'Browser launch requested successfully.'
593
+ : 'Browser auto-open was requested but is not available in this terminal session.'
594
+ : 'Browser auto-open is disabled. Open the URL manually if needed.';
595
+ const nextStep = daemonOnly
596
+ ? 'Use ds --tui to enter the terminal workspace.'
597
+ : mode === 'web'
598
+ ? 'Use ds --tui to enter the terminal workspace.'
599
+ : mode === 'both'
600
+ ? 'The terminal workspace starts below.'
601
+ : 'Use Ctrl+O inside TUI to reopen the web workspace.';
602
+
603
+ console.log('');
604
+ const artwork = renderBrandArtwork();
605
+ for (const line of artwork) {
606
+ console.log(centerText(line, width));
607
+ }
608
+ if (artwork.length === 0) {
609
+ console.log(centerText(colorize('\u001B[1;38;5;39m', '⛰'), width));
610
+ }
611
+ const wordmark = [
612
+ ' ____ ____ _ _ _ _ ',
613
+ ' | _ \\ ___ ___ _ __ / ___| ___(_) ___ _ __ | |_(_)___| |_ ',
614
+ " | | | |/ _ \\/ _ \\ '_ \\\\___ \\ / __| |/ _ \\ '_ \\| __| / __| __|",
615
+ ' | |_| | __/ __/ |_) |___) | (__| | __/ | | | |_| \\__ \\ |_ ',
616
+ ' |____/ \\___|\\___| .__/|____/ \\___|_|\\___|_| |_|\\__|_|___/\\__|',
617
+ ' |_| ',
618
+ ];
619
+ console.log(centerText(title, width));
620
+ console.log(centerText(versionLine, width));
621
+ for (const line of wordmark) {
622
+ console.log(centerText(colorize('\u001B[1;38;5;39m', line), width));
623
+ }
624
+ console.log(centerText(subtitle, width));
625
+ console.log('');
626
+ console.log(centerText(divider, width));
627
+ console.log(centerText(colorize('\u001B[1m', workspaceMode), width));
628
+ console.log(centerText(urlLabel, width));
629
+ console.log(centerText(divider, width));
630
+ console.log(centerText(browserLine, width));
631
+ console.log(centerText(nextStep, width));
632
+ console.log(centerText('Run ds --stop to stop the managed daemon.', width));
633
+ console.log('');
634
+ renderLaunchHints({ home, url, bindUrl, pythonSelection });
635
+ }
636
+
91
637
  function escapeHtml(value) {
92
638
  return String(value ?? '')
93
639
  .replaceAll('&', '&amp;')
@@ -208,7 +754,7 @@ function writeCodexPreflightReport(home, probe) {
208
754
  };
209
755
  }
210
756
 
211
- function readCodexBootstrapState(home, venvPython) {
757
+ function readCodexBootstrapState(home, runtimePython) {
212
758
  const snippet = [
213
759
  'import json, pathlib, sys',
214
760
  'from deepscientist.config import ConfigManager',
@@ -216,7 +762,7 @@ function readCodexBootstrapState(home, venvPython) {
216
762
  'manager = ConfigManager(home)',
217
763
  'print(json.dumps(manager.codex_bootstrap_state(), ensure_ascii=False))',
218
764
  ].join('\n');
219
- const result = runSync(venvPython, ['-c', snippet, home], { capture: true, allowFailure: true });
765
+ const result = runSync(runtimePython, ['-c', snippet, home], { capture: true, allowFailure: true });
220
766
  if (result.status !== 0) {
221
767
  return { codex_ready: false, codex_last_checked_at: null, codex_last_result: {} };
222
768
  }
@@ -227,7 +773,7 @@ function readCodexBootstrapState(home, venvPython) {
227
773
  }
228
774
  }
229
775
 
230
- function probeCodexBootstrap(home, venvPython) {
776
+ function probeCodexBootstrap(home, runtimePython) {
231
777
  const snippet = [
232
778
  'import json, pathlib, sys',
233
779
  'from deepscientist.config import ConfigManager',
@@ -235,7 +781,7 @@ function probeCodexBootstrap(home, venvPython) {
235
781
  'manager = ConfigManager(home)',
236
782
  'print(json.dumps(manager.probe_codex_bootstrap(persist=True), ensure_ascii=False))',
237
783
  ].join('\n');
238
- const result = runSync(venvPython, ['-c', snippet, home], { capture: true, allowFailure: true });
784
+ const result = runSync(runtimePython, ['-c', snippet, home], { capture: true, allowFailure: true });
239
785
  let payload = null;
240
786
  try {
241
787
  payload = JSON.parse(result.stdout || '{}');
@@ -274,16 +820,17 @@ function createCodexPreflightError(home, probe) {
274
820
 
275
821
  function parseLauncherArgs(argv) {
276
822
  const args = [...argv];
277
- let mode = 'both';
823
+ let mode = null;
278
824
  let host = null;
279
825
  let port = null;
280
826
  let home = null;
281
827
  let stop = false;
282
828
  let restart = false;
283
- let openBrowser = false;
829
+ let openBrowser = null;
284
830
  let questId = null;
285
831
  let status = false;
286
832
  let daemonOnly = false;
833
+ let skipUpdateCheck = false;
287
834
 
288
835
  if (args[0] === 'ui') {
289
836
  args.shift();
@@ -301,11 +848,12 @@ function parseLauncherArgs(argv) {
301
848
  else if (arg === '--no-browser') openBrowser = false;
302
849
  else if (arg === '--open-browser') openBrowser = true;
303
850
  else if (arg === '--daemon-only') daemonOnly = true;
851
+ else if (arg === '--skip-update-check') skipUpdateCheck = true;
304
852
  else if (arg === '--host' && args[index + 1]) host = args[++index];
305
853
  else if (arg === '--port' && args[index + 1]) port = Number(args[++index]);
306
854
  else if (arg === '--home' && args[index + 1]) home = path.resolve(args[++index]);
307
855
  else if (arg === '--quest-id' && args[index + 1]) questId = args[++index];
308
- else if (arg === '--mode' && args[index + 1]) mode = args[++index];
856
+ else if (arg === '--mode' && args[index + 1]) mode = normalizeMode(args[++index]);
309
857
  else if (arg === '--help' || arg === '-h') return { help: true };
310
858
  else if (!arg.startsWith('--')) return null;
311
859
  }
@@ -322,6 +870,83 @@ function parseLauncherArgs(argv) {
322
870
  openBrowser,
323
871
  questId,
324
872
  daemonOnly,
873
+ skipUpdateCheck,
874
+ };
875
+ }
876
+
877
+ function printUpdateHelp() {
878
+ console.log(`DeepScientist update
879
+
880
+ Usage:
881
+ ds update
882
+ ds update --check
883
+ ds update --yes
884
+ ds update --remind-later
885
+ ds update --skip-version
886
+
887
+ Flags:
888
+ --check Return the current update status without installing
889
+ --yes Install the latest published npm package immediately
890
+ --json Print structured JSON output
891
+ --force-check Ignore the cached version probe
892
+ --remind-later Defer prompts for the current published version
893
+ --skip-version Skip reminders for the current published version
894
+ `);
895
+ }
896
+
897
+ function parseUpdateArgs(argv) {
898
+ const args = [...argv];
899
+ if (args[0] === 'update') {
900
+ args.shift();
901
+ }
902
+ let json = false;
903
+ let check = false;
904
+ let yes = false;
905
+ let forceCheck = false;
906
+ let remindLater = false;
907
+ let skipVersion = false;
908
+ let background = false;
909
+ let worker = false;
910
+ let home = null;
911
+ let host = null;
912
+ let port = null;
913
+ let restartDaemon = null;
914
+ let skipUpdateCheck = false;
915
+
916
+ for (let index = 0; index < args.length; index += 1) {
917
+ const arg = args[index];
918
+ if (arg === '--json') json = true;
919
+ else if (arg === '--check') check = true;
920
+ else if (arg === '--yes') yes = true;
921
+ else if (arg === '--force-check') forceCheck = true;
922
+ else if (arg === '--remind-later') remindLater = true;
923
+ else if (arg === '--skip-version') skipVersion = true;
924
+ else if (arg === '--background') background = true;
925
+ else if (arg === '--worker') worker = true;
926
+ else if (arg === '--restart-daemon') restartDaemon = true;
927
+ else if (arg === '--skip-update-check') skipUpdateCheck = true;
928
+ else if (arg === '--home' && args[index + 1]) home = path.resolve(args[++index]);
929
+ else if (arg === '--host' && args[index + 1]) host = args[++index];
930
+ else if (arg === '--port' && args[index + 1]) port = Number(args[++index]);
931
+ else if (arg === '--help' || arg === '-h') return { help: true };
932
+ else if (!arg.startsWith('--')) return null;
933
+ }
934
+
935
+ return {
936
+ help: false,
937
+ json,
938
+ check,
939
+ yes,
940
+ forceCheck,
941
+ remindLater,
942
+ skipVersion,
943
+ background,
944
+ worker,
945
+ home,
946
+ host,
947
+ port,
948
+ restartDaemon,
949
+ skipUpdateCheck,
325
950
  };
326
951
  }
327
952
 
@@ -340,39 +965,268 @@ function findFirstPositionalArg(args) {
340
965
  return null;
341
966
  }
342
967
 
343
- function resolveSystemPython() {
344
- for (const binary of pythonCandidates) {
345
- const result = spawnSync(binary, ['--version'], { stdio: 'ignore' });
346
- if (result.status === 0) {
347
- return binary;
968
+ function realpathOrSelf(targetPath) {
969
+ try {
970
+ return fs.realpathSync(targetPath);
971
+ } catch {
972
+ return targetPath;
973
+ }
974
+ }
975
+
976
+ function pythonMeetsMinimum(probe) {
977
+ if (!probe || typeof probe.major !== 'number' || typeof probe.minor !== 'number') {
978
+ return false;
979
+ }
980
+ if (probe.major !== minimumPythonVersion.major) {
981
+ return probe.major > minimumPythonVersion.major;
982
+ }
983
+ if (probe.minor !== minimumPythonVersion.minor) {
984
+ return probe.minor > minimumPythonVersion.minor;
985
+ }
986
+ return probe.patch >= minimumPythonVersion.patch;
987
+ }
988
+
989
+ function pythonSelectionLabel(source) {
990
+ if (source === 'conda') {
991
+ const envName = String(process.env.CONDA_DEFAULT_ENV || '').trim();
992
+ return envName ? `conda:${envName}` : 'conda';
993
+ }
994
+ if (source === 'uv-managed') {
995
+ return 'uv-managed';
996
+ }
997
+ return 'path';
998
+ }
999
+
1000
+ function buildCondaPythonCandidates() {
1001
+ const prefix = String(process.env.CONDA_PREFIX || '').trim();
1002
+ if (!prefix) {
1003
+ return [];
1004
+ }
1005
+ if (process.platform === 'win32') {
1006
+ return [path.join(prefix, 'python.exe'), path.join(prefix, 'Scripts', 'python.exe')];
1007
+ }
1008
+ return [path.join(prefix, 'bin', 'python'), path.join(prefix, 'bin', 'python3')];
1009
+ }
1010
+
1011
+ function probePython(binary) {
1012
+ const snippet = [
1013
+ 'import json, sys',
1014
+ 'print(json.dumps({',
1015
+ ' "executable": sys.executable,',
1016
+ ' "version": ".".join(str(part) for part in sys.version_info[:3]),',
1017
+ ' "major": sys.version_info[0],',
1018
+ ' "minor": sys.version_info[1],',
1019
+ ' "patch": sys.version_info[2],',
1020
+ '}, ensure_ascii=False))',
1021
+ ].join('\n');
1022
+ const result = spawnSync(binary, ['-c', snippet], {
1023
+ encoding: 'utf8',
1024
+ env: process.env,
1025
+ });
1026
+ if (result.error) {
1027
+ return {
1028
+ ok: false,
1029
+ binary,
1030
+ error: result.error.message,
1031
+ };
1032
+ }
1033
+ if (result.status !== 0) {
1034
+ return {
1035
+ ok: false,
1036
+ binary,
1037
+ error: (result.stderr || result.stdout || '').trim() || `exit ${result.status}`,
1038
+ };
1039
+ }
1040
+ try {
1041
+ const payload = JSON.parse(result.stdout || '{}');
1042
+ const executable = String(payload.executable || '').trim();
1043
+ return {
1044
+ ok: true,
1045
+ binary,
1046
+ executable,
1047
+ realExecutable: executable ? realpathOrSelf(executable) : '',
1048
+ version: String(payload.version || '').trim(),
1049
+ major: Number(payload.major),
1050
+ minor: Number(payload.minor),
1051
+ patch: Number(payload.patch),
1052
+ };
1053
+ } catch (error) {
1054
+ return {
1055
+ ok: false,
1056
+ binary,
1057
+ error: error instanceof Error ? error.message : 'Could not parse Python version probe.',
1058
+ };
1059
+ }
1060
+ }
1061
+
1062
+ function minimumPythonRequest() {
1063
+ return `${minimumPythonVersion.major}.${minimumPythonVersion.minor}`;
1064
+ }
1065
+
1066
+ function decoratePythonProbe(probe, source) {
1067
+ if (!probe || !probe.ok) {
1068
+ return null;
1069
+ }
1070
+ return {
1071
+ ...probe,
1072
+ source,
1073
+ sourceLabel: pythonSelectionLabel(source),
1074
+ };
1075
+ }
1076
+
1077
+ function collectPythonProbes(binaries, source, seenExecutables) {
1078
+ const probes = [];
1079
+ for (const candidate of binaries) {
1080
+ const resolved = decoratePythonProbe(probePython(candidate), source);
1081
+ if (!resolved) {
1082
+ continue;
1083
+ }
1084
+ const executableKey = resolved.realExecutable || resolved.executable || resolved.binary;
1085
+ if (seenExecutables.has(executableKey)) {
1086
+ continue;
348
1087
  }
1088
+ seenExecutables.add(executableKey);
1089
+ probes.push(resolved);
349
1090
  }
350
- console.error('DeepScientist could not find a working Python 3 interpreter.');
351
- process.exit(1);
1091
+ return probes;
1092
+ }
1093
+
1094
+ function createPythonRuntimePlan({ condaProbes = [], pathProbes = [], minimumVersionRequest = minimumPythonRequest() }) {
1095
+ const validConda = condaProbes.find((probe) => pythonMeetsMinimum(probe)) || null;
1096
+ if (validConda) {
1097
+ return {
1098
+ runtimeKind: 'system',
1099
+ selectedProbe: validConda,
1100
+ source: 'conda',
1101
+ sourceLabel: validConda.sourceLabel,
1102
+ };
1103
+ }
1104
+ const firstConda = condaProbes[0] || null;
1105
+ if (firstConda) {
1106
+ return {
1107
+ runtimeKind: 'managed',
1108
+ selectedProbe: null,
1109
+ rejectedProbe: firstConda,
1110
+ source: 'conda',
1111
+ sourceLabel: pythonSelectionLabel('uv-managed'),
1112
+ minimumVersionRequest,
1113
+ };
1114
+ }
1115
+
1116
+ const validPath = pathProbes.find((probe) => pythonMeetsMinimum(probe)) || null;
1117
+ if (validPath) {
1118
+ return {
1119
+ runtimeKind: 'system',
1120
+ selectedProbe: validPath,
1121
+ source: 'path',
1122
+ sourceLabel: validPath.sourceLabel,
1123
+ };
1124
+ }
1125
+ const firstPath = pathProbes[0] || null;
1126
+ if (firstPath) {
1127
+ return {
1128
+ runtimeKind: 'managed',
1129
+ selectedProbe: null,
1130
+ rejectedProbe: firstPath,
1131
+ source: 'path',
1132
+ sourceLabel: pythonSelectionLabel('uv-managed'),
1133
+ minimumVersionRequest,
1134
+ };
1135
+ }
1136
+
1137
+ return {
1138
+ runtimeKind: 'managed',
1139
+ selectedProbe: null,
1140
+ rejectedProbe: null,
1141
+ source: 'uv-managed',
1142
+ sourceLabel: pythonSelectionLabel('uv-managed'),
1143
+ minimumVersionRequest,
1144
+ };
352
1145
  }
353
1146
 
354
- function venvPythonPath(home) {
1147
+ function printManagedPythonFallbackNotice({ rejectedProbe, source, minimumVersionRequest, installDir }) {
1148
+ if (!rejectedProbe) {
1149
+ return;
1150
+ }
1151
+ const envName = String(process.env.CONDA_DEFAULT_ENV || '').trim();
1152
+ const sourceLabel =
1153
+ source === 'conda'
1154
+ ? (envName ? `active conda environment \`${envName}\`` : 'active conda environment')
1155
+ : 'detected system Python';
1156
+ console.warn('');
1157
+ console.warn(
1158
+ `DeepScientist found ${sourceLabel} at ${pythonVersionText(rejectedProbe)}, which does not satisfy Python ${requiredPythonSpec}.`
1159
+ );
1160
+ console.warn(
1161
+ `DeepScientist will provision a uv-managed Python ${minimumVersionRequest}+ runtime under ${installDir}.`
1162
+ );
1163
+ console.warn('');
1164
+ }
1165
+
1166
+ function resolvePythonRuntimePlan() {
1167
+ const seenExecutables = new Set();
1168
+ const condaProbes = collectPythonProbes(buildCondaPythonCandidates(), 'conda', seenExecutables);
1169
+ const pathProbes = collectPythonProbes(pythonCandidates, 'path', seenExecutables);
1170
+ return createPythonRuntimePlan({ condaProbes, pathProbes, minimumVersionRequest: minimumPythonRequest() });
1171
+ }
1172
+
1173
+ function runtimePythonEnvPath(home) {
1174
+ return path.join(home, 'runtime', 'python-env');
1175
+ }
1176
+
1177
+ function runtimePythonPath(home) {
355
1178
  return process.platform === 'win32'
356
- ? path.join(home, 'runtime', 'venv', 'Scripts', 'python.exe')
357
- : path.join(home, 'runtime', 'venv', 'bin', 'python');
1179
+ ? path.join(runtimePythonEnvPath(home), 'Scripts', 'python.exe')
1180
+ : path.join(runtimePythonEnvPath(home), 'bin', 'python');
1181
+ }
1182
+
1183
+ function runtimeUvCachePath(home) {
1184
+ return path.join(home, 'runtime', 'uv-cache');
1185
+ }
1186
+
1187
+ function runtimeUvPythonInstallPath(home) {
1188
+ return path.join(home, 'runtime', 'python');
358
1189
  }
359
1190
 
360
- function venvRootPath(home) {
1191
+ function runtimeToolsPath(home) {
1192
+ return path.join(home, 'runtime', 'tools');
1193
+ }
1194
+
1195
+ function runtimeUvRootPath(home) {
1196
+ return path.join(runtimeToolsPath(home), 'uv');
1197
+ }
1198
+
1199
+ function runtimeUvBinDir(home) {
1200
+ return path.join(runtimeUvRootPath(home), 'bin');
1201
+ }
1202
+
1203
+ function runtimeUvBinaryPath(home) {
1204
+ return path.join(runtimeUvBinDir(home), process.platform === 'win32' ? 'uv.exe' : 'uv');
1205
+ }
1206
+
1207
+ function legacyVenvRootPath(home) {
361
1208
  return path.join(home, 'runtime', 'venv');
362
1209
  }
363
1210
 
1211
+ function useEditableProjectInstall() {
1212
+ return fs.existsSync(path.join(repoRoot, '.git'));
1213
+ }
1214
+
1215
+ function uvLockPath() {
1216
+ return path.join(repoRoot, 'uv.lock');
1217
+ }
1218
+
364
1219
  function sha256File(filePath) {
365
1220
  return crypto.createHash('sha256').update(fs.readFileSync(filePath)).digest('hex');
366
1221
  }
367
1222
 
368
- function hashSkillTree() {
369
- const skillsRoot = path.join(repoRoot, 'src', 'skills');
1223
+ function hashDirectoryTree(rootPath, predicate = null) {
370
1224
  const hasher = crypto.createHash('sha256');
371
- if (!fs.existsSync(skillsRoot)) {
1225
+ if (!fs.existsSync(rootPath)) {
372
1226
  hasher.update('missing');
373
1227
  return hasher.digest('hex');
374
1228
  }
375
- const stack = [skillsRoot];
1229
+ const stack = [rootPath];
376
1230
  const files = [];
377
1231
  while (stack.length > 0) {
378
1232
  const current = stack.pop();
@@ -383,18 +1237,29 @@ function hashSkillTree() {
383
1237
  continue;
384
1238
  }
385
1239
  if (entry.isFile()) {
1240
+ if (typeof predicate === 'function' && !predicate(fullPath)) {
1241
+ continue;
1242
+ }
386
1243
  files.push(fullPath);
387
1244
  }
388
1245
  }
389
1246
  }
390
1247
  files.sort();
391
1248
  for (const filePath of files) {
392
- hasher.update(path.relative(skillsRoot, filePath));
1249
+ hasher.update(path.relative(rootPath, filePath));
393
1250
  hasher.update(fs.readFileSync(filePath));
394
1251
  }
395
1252
  return hasher.digest('hex');
396
1253
  }
397
1254
 
1255
+ function hashSkillTree() {
1256
+ return hashDirectoryTree(path.join(repoRoot, 'src', 'skills'));
1257
+ }
1258
+
1259
+ function hashPythonSourceTree() {
1260
+ return hashDirectoryTree(path.join(repoRoot, 'src', 'deepscientist'), (filePath) => filePath.endsWith('.py'));
1261
+ }
1262
+
398
1263
  function discoverSkillIds() {
399
1264
  const skillsRoot = path.join(repoRoot, 'src', 'skills');
400
1265
  if (!fs.existsSync(skillsRoot)) {
@@ -425,15 +1290,11 @@ function globalSkillsInstalled() {
425
1290
 
426
1291
  function runSync(binary, args, options = {}) {
427
1292
  const result = spawnSync(binary, args, {
428
- cwd: repoRoot,
1293
+ cwd: options.cwd || repoRoot,
429
1294
  stdio: options.capture ? 'pipe' : 'inherit',
430
- env: {
431
- ...process.env,
432
- PYTHONPATH: process.env.PYTHONPATH
433
- ? `${srcPath}${path.delimiter}${process.env.PYTHONPATH}`
434
- : srcPath,
435
- },
1295
+ env: options.env || process.env,
436
1296
  encoding: 'utf8',
1297
+ input: options.input,
437
1298
  });
438
1299
  if (result.error) {
439
1300
  throw result.error;
@@ -451,72 +1312,468 @@ function step(index, total, message) {
451
1312
  console.log(`[${index}/${total}] ${message}`);
452
1313
  }
453
1314
 
454
- function installPythonBundle(venvPython) {
455
- runSync(venvPython, ['-m', 'pip', 'install', '--upgrade', 'pip', 'setuptools', 'wheel']);
456
- runSync(venvPython, ['-m', 'pip', 'install', '--upgrade', repoRoot]);
457
- }
458
-
459
- function verifyPythonRuntime(venvPython) {
1315
+ function verifyPythonRuntime(runtimePython) {
460
1316
  const result = runSync(
461
- venvPython,
1317
+ runtimePython,
462
1318
  ['-c', 'import deepscientist.cli; import cryptography; import _cffi_backend; print("ok")'],
463
1319
  { capture: true, allowFailure: true }
464
1320
  );
465
1321
  return result.status === 0;
466
1322
  }
467
1323
 
468
- function recreatePythonRuntime(home, systemPython) {
469
- fs.rmSync(venvRootPath(home), { recursive: true, force: true });
470
- step(1, 4, 'Creating local Python runtime');
471
- runSync(systemPython, ['-m', 'venv', venvRootPath(home)]);
1324
+ function readJsonFile(filePath) {
1325
+ if (!fs.existsSync(filePath)) {
1326
+ return null;
1327
+ }
1328
+ try {
1329
+ return JSON.parse(fs.readFileSync(filePath, 'utf8'));
1330
+ } catch {
1331
+ return null;
1332
+ }
1333
+ }
1334
+
1335
+ function executableExtensions() {
1336
+ if (process.platform !== 'win32') {
1337
+ return [''];
1338
+ }
1339
+ return (process.env.PATHEXT || '.EXE;.CMD;.BAT;.COM')
1340
+ .split(';')
1341
+ .filter(Boolean);
1342
+ }
1343
+
1344
+ function candidateExecutablePaths(basePath) {
1345
+ if (process.platform !== 'win32') {
1346
+ return [basePath];
1347
+ }
1348
+ const extension = path.extname(basePath);
1349
+ if (extension) {
1350
+ return [basePath];
1351
+ }
1352
+ return executableExtensions().map((suffix) => `${basePath}${suffix}`);
1353
+ }
1354
+
1355
+ function isExecutableFile(candidate) {
1356
+ try {
1357
+ if (!fs.existsSync(candidate)) {
1358
+ return false;
1359
+ }
1360
+ const stat = fs.statSync(candidate);
1361
+ if (!stat.isFile()) {
1362
+ return false;
1363
+ }
1364
+ if (process.platform !== 'win32') {
1365
+ fs.accessSync(candidate, fs.constants.X_OK);
1366
+ }
1367
+ return true;
1368
+ } catch {
1369
+ return false;
1370
+ }
1371
+ }
1372
+
1373
+ function resolveBinaryReference(reference) {
1374
+ const normalized = String(reference || '').trim();
1375
+ if (!normalized) {
1376
+ return null;
1377
+ }
1378
+ const expanded = expandUserPath(normalized);
1379
+ if (
1380
+ path.isAbsolute(expanded)
1381
+ || normalized.startsWith('.')
1382
+ || normalized.includes(path.sep)
1383
+ || (path.sep === '\\' ? normalized.includes('/') : normalized.includes('\\'))
1384
+ ) {
1385
+ const absolute = path.isAbsolute(expanded) ? expanded : path.resolve(expanded);
1386
+ for (const candidate of candidateExecutablePaths(absolute)) {
1387
+ if (isExecutableFile(candidate)) {
1388
+ return candidate;
1389
+ }
1390
+ }
1391
+ return null;
1392
+ }
1393
+ return resolveExecutableOnPath(expanded);
1394
+ }
1395
+
1396
+ function resolveUvBinary(home) {
1397
+ const configured = String(process.env.DEEPSCIENTIST_UV || process.env.UV_BIN || '').trim();
1398
+ if (configured) {
1399
+ return {
1400
+ path: resolveBinaryReference(configured),
1401
+ source: 'env',
1402
+ configured,
1403
+ };
1404
+ }
1405
+ const local = resolveBinaryReference(runtimeUvBinaryPath(home));
1406
+ if (local) {
1407
+ return {
1408
+ path: local,
1409
+ source: 'local',
1410
+ configured: null,
1411
+ };
1412
+ }
1413
+ const discovered = resolveExecutableOnPath('uv');
1414
+ return {
1415
+ path: discovered,
1416
+ source: discovered ? 'path' : null,
1417
+ configured: null,
1418
+ };
1419
+ }
1420
+
1421
+ function printUvInstallGuidance(home, errorMessage = null) {
1422
+ console.error('');
1423
+ if (errorMessage) {
1424
+ console.error(`DeepScientist could not prepare a local uv runtime manager: ${errorMessage}`);
1425
+ } else {
1426
+ console.error('DeepScientist could not find a usable uv runtime manager.');
1427
+ }
1428
+ console.error(`DeepScientist normally installs uv automatically under ${runtimeUvBinDir(home)}.`);
1429
+ console.error('If the automatic bootstrap fails, install uv manually and run `ds` again.');
1430
+ console.error('');
1431
+ if (process.platform === 'win32') {
1432
+ console.error('Windows PowerShell:');
1433
+ console.error(' powershell -ExecutionPolicy ByPass -c "irm https://astral.sh/uv/install.ps1 | iex"');
1434
+ } else {
1435
+ console.error('macOS / Linux:');
1436
+ console.error(' curl -LsSf https://astral.sh/uv/install.sh | sh');
1437
+ }
1438
+ console.error('Alternative:');
1439
+ console.error(' pipx install uv');
1440
+ console.error('');
1441
+ }
1442
+
1443
+ function downloadFileWithNode(url, destinationPath) {
1444
+ const downloader = [
1445
+ 'const fs = require("node:fs");',
1446
+ 'const url = process.argv[1];',
1447
+ 'const destination = process.argv[2];',
1448
+ 'const timeoutMs = Number(process.argv[3] || "45000");',
1449
+ '(async () => {',
1450
+ ' const controller = new AbortController();',
1451
+ ' const timer = setTimeout(() => controller.abort(), timeoutMs);',
1452
+ ' try {',
1453
+ ' const response = await fetch(url, { signal: controller.signal });',
1454
+ ' if (!response.ok) {',
1455
+ ' throw new Error(`HTTP ${response.status} ${response.statusText}`);',
1456
+ ' }',
1457
+ ' const body = await response.text();',
1458
+ ' fs.writeFileSync(destination, body, "utf8");',
1459
+ ' } finally {',
1460
+ ' clearTimeout(timer);',
1461
+ ' }',
1462
+ '})().catch((error) => {',
1463
+ ' console.error(error instanceof Error ? error.message : String(error));',
1464
+ ' process.exit(1);',
1465
+ '});',
1466
+ ].join('\n');
1467
+ const result = spawnSync(process.execPath, ['-e', downloader, url, destinationPath, '45000'], {
1468
+ cwd: repoRoot,
1469
+ stdio: 'inherit',
1470
+ env: process.env,
1471
+ });
1472
+ if (result.error) {
1473
+ throw result.error;
1474
+ }
1475
+ if (result.status !== 0) {
1476
+ throw new Error(`Download failed with status ${result.status ?? 1}.`);
1477
+ }
1478
+ }
1479
+
1480
+ function installLocalUv(home) {
1481
+ const uvRoot = runtimeUvRootPath(home);
1482
+ const binDir = runtimeUvBinDir(home);
1483
+ const tempDir = path.join(uvRoot, 'tmp');
1484
+ const installerName = process.platform === 'win32' ? 'install-uv.ps1' : 'install-uv.sh';
1485
+ const installerUrl =
1486
+ process.platform === 'win32'
1487
+ ? 'https://astral.sh/uv/install.ps1'
1488
+ : 'https://astral.sh/uv/install.sh';
1489
+ const installerPath = path.join(tempDir, installerName);
1490
+
1491
+ ensureDir(binDir);
1492
+ ensureDir(tempDir);
1493
+
1494
+ console.log(`DeepScientist is installing a local uv runtime manager under ${binDir}.`);
1495
+ downloadFileWithNode(installerUrl, installerPath);
1496
+
1497
+ const installEnv = {
1498
+ ...process.env,
1499
+ UV_UNMANAGED_INSTALL: binDir,
1500
+ };
1501
+
1502
+ let shellBinary;
1503
+ let shellArgs;
1504
+ if (process.platform === 'win32') {
1505
+ shellBinary =
1506
+ resolveExecutableOnPath('powershell.exe')
1507
+ || resolveExecutableOnPath('powershell')
1508
+ || resolveExecutableOnPath('pwsh.exe')
1509
+ || resolveExecutableOnPath('pwsh');
1510
+ if (!shellBinary) {
1511
+ throw new Error('PowerShell is not available to run the official uv installer.');
1512
+ }
1513
+ shellArgs = ['-ExecutionPolicy', 'ByPass', '-File', installerPath];
1514
+ } else {
1515
+ shellBinary = resolveExecutableOnPath('sh');
1516
+ if (!shellBinary) {
1517
+ throw new Error('`sh` is not available to run the official uv installer.');
1518
+ }
1519
+ shellArgs = [installerPath];
1520
+ }
1521
+
1522
+ const installResult = spawnSync(shellBinary, shellArgs, {
1523
+ cwd: repoRoot,
1524
+ stdio: 'inherit',
1525
+ env: installEnv,
1526
+ });
1527
+ if (installResult.error) {
1528
+ throw installResult.error;
1529
+ }
1530
+ if (installResult.status !== 0) {
1531
+ throw new Error(`The official uv installer exited with status ${installResult.status ?? 1}.`);
1532
+ }
1533
+
1534
+ const installedBinary = resolveBinaryReference(runtimeUvBinaryPath(home));
1535
+ if (!installedBinary) {
1536
+ throw new Error(`uv installation finished, but no executable was found under ${binDir}.`);
1537
+ }
1538
+ return installedBinary;
1539
+ }
1540
+
1541
+ function ensureUvBinary(home) {
1542
+ const resolved = resolveUvBinary(home);
1543
+ if (resolved.path) {
1544
+ return resolved.path;
1545
+ }
1546
+ if (resolved.source === 'env' && resolved.configured) {
1547
+ throw new Error(`Configured uv binary could not be resolved: ${resolved.configured}`);
1548
+ }
1549
+ return installLocalUv(home);
1550
+ }
1551
+
1552
+ function buildUvRuntimeEnv(home, extraEnv = {}) {
1553
+ return {
1554
+ ...process.env,
1555
+ UV_CACHE_DIR: runtimeUvCachePath(home),
1556
+ UV_PROJECT_ENVIRONMENT: runtimePythonEnvPath(home),
1557
+ UV_PYTHON_INSTALL_DIR: runtimeUvPythonInstallPath(home),
1558
+ ...extraEnv,
1559
+ };
1560
+ }
1561
+
1562
+ function ensureUvLockPresent() {
1563
+ const lockPath = uvLockPath();
1564
+ if (fs.existsSync(lockPath)) {
1565
+ return lockPath;
1566
+ }
1567
+ console.error('DeepScientist is missing `uv.lock` in the installed package.');
1568
+ console.error('Reinstall the npm package, or from a source checkout run `uv lock` and try again.');
1569
+ process.exit(1);
1570
+ }
1571
+
1572
+ function resolveUvVersion(uvBinary) {
1573
+ const result = runSync(uvBinary, ['--version'], { capture: true, allowFailure: true });
1574
+ if (result.status !== 0) {
1575
+ return null;
1576
+ }
1577
+ return String(result.stdout || '').trim() || null;
1578
+ }
1579
+
1580
+ function ensureUvManagedPython(home, uvBinary, minimumVersionRequest) {
1581
+ ensureDir(runtimeUvPythonInstallPath(home));
1582
+ ensureDir(runtimeUvCachePath(home));
1583
+ step(1, 4, `Provisioning uv-managed Python ${minimumVersionRequest}+`);
1584
+ const installResult = runSync(
1585
+ uvBinary,
1586
+ ['python', 'install', minimumVersionRequest],
1587
+ {
1588
+ allowFailure: true,
1589
+ env: buildUvRuntimeEnv(home),
1590
+ }
1591
+ );
1592
+ if (installResult.status !== 0) {
1593
+ console.error('DeepScientist could not install a uv-managed Python runtime.');
1594
+ process.exit(installResult.status ?? 1);
1595
+ }
1596
+
1597
+ const findResult = runSync(
1598
+ uvBinary,
1599
+ ['python', 'find', '--managed-python', minimumVersionRequest],
1600
+ {
1601
+ capture: true,
1602
+ allowFailure: true,
1603
+ env: buildUvRuntimeEnv(home),
1604
+ }
1605
+ );
1606
+ const managedPython = String(findResult.stdout || '')
1607
+ .trim()
1608
+ .split(/\r?\n/)
1609
+ .filter(Boolean)
1610
+ .pop();
1611
+ if (!managedPython) {
1612
+ console.error('DeepScientist installed uv-managed Python, but could not locate the interpreter afterward.');
1613
+ process.exit(findResult.status ?? 1);
1614
+ }
1615
+ const probe = decoratePythonProbe(probePython(managedPython), 'uv-managed');
1616
+ if (!probe || !pythonMeetsMinimum(probe)) {
1617
+ console.error('DeepScientist found a uv-managed Python, but it does not satisfy the required version.');
1618
+ process.exit(1);
1619
+ }
1620
+ return probe;
1621
+ }
1622
+
1623
+ function syncUvProjectEnvironment(home, uvBinary, pythonTarget, editable) {
1624
+ const args = ['sync', '--frozen', '--no-dev', '--compile-bytecode', '--python', pythonTarget];
1625
+ if (!editable) {
1626
+ args.push('--no-editable');
1627
+ }
1628
+ step(2, 4, 'Syncing locked Python environment');
1629
+ const result = runSync(uvBinary, args, {
1630
+ allowFailure: true,
1631
+ env: buildUvRuntimeEnv(home),
1632
+ });
1633
+ if (result.status === 0) {
1634
+ return;
1635
+ }
1636
+ console.error('DeepScientist could not sync the locked Python environment with uv.');
1637
+ console.error('If you are working from a source checkout, run `uv lock` after dependency changes and try again.');
1638
+ process.exit(result.status ?? 1);
1639
+ }
1640
+
1641
+ function createRuntimeSelectionProbe(runtimeProbe, sourceLabel) {
1642
+ return {
1643
+ ...runtimeProbe,
1644
+ sourceLabel,
1645
+ };
472
1646
  }
473
1647
 
474
1648
  function ensurePythonRuntime(home) {
475
1649
  ensureDir(path.join(home, 'runtime'));
476
1650
  ensureDir(path.join(home, 'runtime', 'bundle'));
477
- const systemPython = resolveSystemPython();
1651
+ ensureDir(runtimeUvCachePath(home));
1652
+ ensureDir(runtimeUvPythonInstallPath(home));
1653
+ ensureDir(runtimeToolsPath(home));
1654
+ let uvBinary;
1655
+ try {
1656
+ uvBinary = ensureUvBinary(home);
1657
+ } catch (error) {
1658
+ printUvInstallGuidance(home, error instanceof Error ? error.message : String(error));
1659
+ process.exit(1);
1660
+ }
1661
+ const runtimePlan = resolvePythonRuntimePlan();
1662
+ if (runtimePlan.runtimeKind === 'managed') {
1663
+ printManagedPythonFallbackNotice({
1664
+ rejectedProbe: runtimePlan.rejectedProbe || null,
1665
+ source: runtimePlan.source,
1666
+ minimumVersionRequest: runtimePlan.minimumVersionRequest,
1667
+ installDir: runtimeUvPythonInstallPath(home),
1668
+ });
1669
+ }
1670
+ const lockPath = ensureUvLockPresent();
478
1671
  const stampPath = path.join(home, 'runtime', 'bundle', 'python-stamp.json');
1672
+ const editable = useEditableProjectInstall();
479
1673
  const desiredStamp = {
1674
+ runtimeManager: 'uv',
480
1675
  version: packageJson.version,
481
1676
  pyprojectHash: sha256File(path.join(repoRoot, 'pyproject.toml')),
1677
+ uvLockHash: sha256File(lockPath),
1678
+ editable,
1679
+ sourceTreeHash: editable ? null : hashPythonSourceTree(),
1680
+ uvVersion: resolveUvVersion(uvBinary),
1681
+ envPath: runtimePythonEnvPath(home),
1682
+ source:
1683
+ runtimePlan.runtimeKind === 'system'
1684
+ ? {
1685
+ kind: 'system',
1686
+ source: runtimePlan.selectedProbe.source,
1687
+ sourceExecutable:
1688
+ runtimePlan.selectedProbe.realExecutable
1689
+ || runtimePlan.selectedProbe.executable
1690
+ || runtimePlan.selectedProbe.binary,
1691
+ sourceVersion: runtimePlan.selectedProbe.version,
1692
+ sourceMajorMinor: pythonMajorMinor(runtimePlan.selectedProbe),
1693
+ }
1694
+ : {
1695
+ kind: 'uv-managed',
1696
+ minimumVersionRequest: runtimePlan.minimumVersionRequest,
1697
+ },
482
1698
  };
483
1699
 
484
1700
  for (let attempt = 0; attempt < 2; attempt += 1) {
485
- const venvPython = venvPythonPath(home);
486
- if (!fs.existsSync(venvPython)) {
487
- recreatePythonRuntime(home, systemPython);
488
- }
489
-
490
- let currentStamp = null;
491
- if (fs.existsSync(stampPath)) {
492
- try {
493
- currentStamp = JSON.parse(fs.readFileSync(stampPath, 'utf8'));
494
- } catch {
495
- currentStamp = null;
1701
+ const runtimePython = runtimePythonPath(home);
1702
+ const currentStamp = readJsonFile(stampPath);
1703
+ const runtimeProbe = fs.existsSync(runtimePython) ? probePython(runtimePython) : null;
1704
+ const runtimeBroken = !runtimeProbe || !runtimeProbe.ok || !pythonMeetsMinimum(runtimeProbe);
1705
+ const stampChanged = JSON.stringify(currentStamp || null) !== JSON.stringify(desiredStamp);
1706
+
1707
+ if (runtimeBroken || stampChanged) {
1708
+ const reason = runtimeBroken
1709
+ ? 'DeepScientist is repairing the local uv-managed Python runtime.'
1710
+ : 'DeepScientist detected a runtime change and is rebuilding the local uv-managed environment.';
1711
+ console.warn(reason);
1712
+ fs.rmSync(stampPath, { force: true });
1713
+ fs.rmSync(runtimePythonEnvPath(home), { recursive: true, force: true });
1714
+
1715
+ let pythonTarget = null;
1716
+ let sourceLabel = null;
1717
+ if (runtimePlan.runtimeKind === 'system' && runtimePlan.selectedProbe) {
1718
+ pythonTarget =
1719
+ runtimePlan.selectedProbe.realExecutable
1720
+ || runtimePlan.selectedProbe.executable
1721
+ || runtimePlan.selectedProbe.binary;
1722
+ sourceLabel = `${runtimePlan.selectedProbe.sourceLabel} via uv-env`;
1723
+ step(1, 4, 'Preparing uv-managed Python runtime');
1724
+ } else {
1725
+ const managedPython = ensureUvManagedPython(home, uvBinary, runtimePlan.minimumVersionRequest);
1726
+ pythonTarget = managedPython.realExecutable || managedPython.executable || managedPython.binary;
1727
+ sourceLabel = managedPython.sourceLabel;
496
1728
  }
497
- }
498
1729
 
499
- if (!currentStamp || currentStamp.version !== desiredStamp.version || currentStamp.pyprojectHash !== desiredStamp.pyprojectHash) {
500
- step(2, 4, 'Installing Python package and dependencies');
501
- installPythonBundle(venvPython);
1730
+ syncUvProjectEnvironment(home, uvBinary, pythonTarget, editable);
502
1731
  fs.writeFileSync(stampPath, `${JSON.stringify(desiredStamp, null, 2)}\n`, 'utf8');
1732
+ const syncedProbe = fs.existsSync(runtimePython) ? probePython(runtimePython) : null;
1733
+ if (syncedProbe && syncedProbe.ok && pythonMeetsMinimum(syncedProbe) && verifyPythonRuntime(runtimePython)) {
1734
+ fs.rmSync(legacyVenvRootPath(home), { recursive: true, force: true });
1735
+ return {
1736
+ runtimePython,
1737
+ uvBinary,
1738
+ runtimeManager: 'uv',
1739
+ runtimeProbe: createRuntimeSelectionProbe(syncedProbe, sourceLabel || 'uv-managed'),
1740
+ sourcePython: runtimePlan.selectedProbe || null,
1741
+ };
1742
+ }
503
1743
  }
504
1744
 
505
- if (verifyPythonRuntime(venvPython)) {
506
- return venvPython;
1745
+ if (runtimeProbe && runtimeProbe.ok && pythonMeetsMinimum(runtimeProbe) && verifyPythonRuntime(runtimePython)) {
1746
+ fs.rmSync(legacyVenvRootPath(home), { recursive: true, force: true });
1747
+ return {
1748
+ runtimePython,
1749
+ uvBinary,
1750
+ runtimeManager: 'uv',
1751
+ runtimeProbe: createRuntimeSelectionProbe(
1752
+ runtimeProbe,
1753
+ runtimePlan.runtimeKind === 'system' && runtimePlan.selectedProbe
1754
+ ? `${runtimePlan.selectedProbe.sourceLabel} via uv-env`
1755
+ : 'uv-managed'
1756
+ ),
1757
+ sourcePython: runtimePlan.selectedProbe || null,
1758
+ };
507
1759
  }
508
1760
 
509
- console.warn('DeepScientist is repairing the local Python runtime...');
1761
+ console.warn('DeepScientist is retrying the local uv-managed Python runtime repair.');
510
1762
  fs.rmSync(stampPath, { force: true });
511
- fs.rmSync(venvRootPath(home), { recursive: true, force: true });
1763
+ fs.rmSync(runtimePythonEnvPath(home), { recursive: true, force: true });
512
1764
  }
513
1765
 
514
- console.error('DeepScientist could not prepare a healthy local Python runtime.');
1766
+ console.error('DeepScientist could not prepare a healthy uv-managed Python runtime.');
515
1767
  process.exit(1);
516
1768
  }
517
1769
 
518
- function runPythonCli(venvPython, args, options = {}) {
519
- return runSync(venvPython, ['-m', 'deepscientist.cli', ...args], options);
1770
+ function runPythonCli(runtimePython, args, options = {}) {
1771
+ const env = {
1772
+ ...process.env,
1773
+ DEEPSCIENTIST_REPO_ROOT: repoRoot,
1774
+ ...(options.env || {}),
1775
+ };
1776
+ return runSync(runtimePython, ['-m', 'deepscientist.cli', ...args], { ...options, env });
520
1777
  }
521
1778
 
522
1779
  function normalizePythonCliArgs(args, home) {
@@ -532,7 +1789,7 @@ function normalizePythonCliArgs(args, home) {
532
1789
  return ['--home', home, ...normalized];
533
1790
  }
534
1791
 
535
- function ensureInitialized(home, venvPython) {
1792
+ function ensureInitialized(home, runtimePython) {
536
1793
  const stampPath = path.join(home, 'runtime', 'bundle', 'init-stamp.json');
537
1794
  let currentStamp = null;
538
1795
  if (fs.existsSync(stampPath)) {
@@ -557,7 +1814,7 @@ function ensureInitialized(home, venvPython) {
557
1814
  return;
558
1815
  }
559
1816
  step(3, 4, 'Preparing DeepScientist home, config, skills, and Git checks');
560
- const result = runPythonCli(venvPython, ['--home', home, 'init'], { capture: true, allowFailure: true });
1817
+ const result = runPythonCli(runtimePython, ['--home', home, 'init'], { capture: true, allowFailure: true });
561
1818
  const stdout = result.stdout || '';
562
1819
  let payload = {};
563
1820
  try {
@@ -616,34 +1873,11 @@ function resolveExecutableOnPath(commandName) {
616
1873
  return null;
617
1874
  }
618
1875
  const directories = pathValue.split(path.delimiter).filter(Boolean);
619
- const extensions =
620
- process.platform === 'win32'
621
- ? (process.env.PATHEXT || '.EXE;.CMD;.BAT;.COM')
622
- .split(';')
623
- .filter(Boolean)
624
- : [''];
625
1876
  for (const directory of directories) {
626
1877
  const base = path.join(directory, commandName);
627
- for (const extension of extensions) {
628
- const candidate = process.platform === 'win32' ? `${base}${extension}` : base;
629
- try {
630
- if (!fs.existsSync(candidate)) {
631
- continue;
632
- }
633
- const stat = fs.statSync(candidate);
634
- if (!stat.isFile()) {
635
- continue;
636
- }
637
- if (process.platform !== 'win32') {
638
- try {
639
- fs.accessSync(candidate, fs.constants.X_OK);
640
- } catch {
641
- continue;
642
- }
643
- }
1878
+ for (const candidate of candidateExecutablePaths(base)) {
1879
+ if (isExecutableFile(candidate)) {
644
1880
  return candidate;
645
- } catch {
646
- continue;
647
1881
  }
648
1882
  }
649
1883
  }
@@ -956,40 +2190,523 @@ async function stopDaemon(home) {
956
2190
  console.log('DeepScientist daemon stopped.');
957
2191
  }
958
2192
 
959
- async function readConfiguredUiAddress(home, venvPython, fallbackHost, fallbackPort) {
2193
+ function writeUpdateLog(home, content) {
2194
+ const logPath = path.join(home, 'logs', 'update.log');
2195
+ ensureDir(path.dirname(logPath));
2196
+ fs.appendFileSync(logPath, `${content.replace(/\s+$/, '')}\n`, 'utf8');
2197
+ return logPath;
2198
+ }
2199
+
2200
+ function summarizeUpdateFailure(result) {
2201
+ const lines = [];
2202
+ if (result.error) {
2203
+ lines.push(result.error);
2204
+ }
2205
+ if (result.stderr) {
2206
+ lines.push(String(result.stderr).trim());
2207
+ }
2208
+ if (result.stdout) {
2209
+ lines.push(String(result.stdout).trim());
2210
+ }
2211
+ return lines.filter(Boolean).join('\n').trim() || 'Unknown update failure.';
2212
+ }
2213
+
2214
+ function runNpmInstallLatest(home, npmBinary) {
2215
+ const args = ['install', '-g', `${UPDATE_PACKAGE_NAME}@latest`, '--no-audit', '--no-fund'];
2216
+ const startedAt = new Date().toISOString();
2217
+ const result = spawnSync(npmBinary, args, {
2218
+ encoding: 'utf8',
2219
+ env: process.env,
2220
+ timeout: 15 * 60 * 1000,
2221
+ });
2222
+ const finishedAt = new Date().toISOString();
2223
+ const logPath = writeUpdateLog(
2224
+ home,
2225
+ [
2226
+ `=== ${startedAt} installing ${UPDATE_PACKAGE_NAME}@latest ===`,
2227
+ `$ ${npmBinary} ${args.join(' ')}`,
2228
+ String(result.stdout || '').trim(),
2229
+ String(result.stderr || '').trim(),
2230
+ `exit=${result.status ?? 'null'} error=${result.error ? result.error.message : 'none'}`,
2231
+ `=== finished ${finishedAt} ===`,
2232
+ '',
2233
+ ].join('\n')
2234
+ );
2235
+ return {
2236
+ ok: !result.error && result.status === 0,
2237
+ stdout: String(result.stdout || ''),
2238
+ stderr: String(result.stderr || ''),
2239
+ error: result.error ? result.error.message : null,
2240
+ status: result.status ?? null,
2241
+ logPath,
2242
+ };
2243
+ }
2244
+
2245
+ async function promptUpdateAction(status) {
2246
+ const options = [
2247
+ {
2248
+ value: 'update',
2249
+ label: status.can_self_update ? 'Update now' : 'Show manual update',
2250
+ },
2251
+ { value: 'later', label: 'Remind me later' },
2252
+ { value: 'skip', label: 'Skip this version' },
2253
+ ];
2254
+ if (!process.stdin.isTTY || !process.stdout.isTTY) {
2255
+ return 'later';
2256
+ }
2257
+ return new Promise((resolve) => {
2258
+ let selected = 1;
2259
+ const lines = [
2260
+ '',
2261
+ 'A new DeepScientist version is available.',
2262
+ '',
2263
+ `Current: ${status.current_version}`,
2264
+ `Latest: ${status.latest_version || 'unknown'}`,
2265
+ '',
2266
+ status.can_self_update
2267
+ ? 'What do you want to do?'
2268
+ : 'Self-update is not available for this installation. Choose an action:',
2269
+ ];
2270
+
2271
+ const cleanup = () => {
2272
+ process.stdin.off('keypress', onKeypress);
2273
+ if (process.stdin.isTTY) {
2274
+ process.stdin.setRawMode(false);
2275
+ }
2276
+ process.stdin.pause();
2277
+ console.log('');
2278
+ };
2279
+
2280
+ const render = () => {
2281
+ console.clear();
2282
+ for (const line of lines) {
2283
+ console.log(line);
2284
+ }
2285
+ for (let index = 0; index < options.length; index += 1) {
2286
+ const option = options[index];
2287
+ console.log(`${index === selected ? '>' : ' '} ${option.label}`);
2288
+ }
2289
+ console.log('');
2290
+ console.log('Use ↑/↓ and Enter.');
2291
+ };
2292
+
2293
+ const onKeypress = (_str, key) => {
2294
+ if (key?.name === 'up') {
2295
+ selected = (selected - 1 + options.length) % options.length;
2296
+ render();
2297
+ return;
2298
+ }
2299
+ if (key?.name === 'down') {
2300
+ selected = (selected + 1) % options.length;
2301
+ render();
2302
+ return;
2303
+ }
2304
+ if (key?.name === 'return') {
2305
+ const choice = options[selected].value;
2306
+ cleanup();
2307
+ resolve(choice);
2308
+ return;
2309
+ }
2310
+ if (key?.ctrl && key?.name === 'c') {
2311
+ cleanup();
2312
+ resolve('later');
2313
+ }
2314
+ };
2315
+
2316
+ readline.emitKeypressEvents(process.stdin);
2317
+ process.stdin.setRawMode(true);
2318
+ process.stdin.resume();
2319
+ process.stdin.on('keypress', onKeypress);
2320
+ render();
2321
+ });
2322
+ }
2323
+
2324
+ function printUpdateStatus(status, { compact = false } = {}) {
2325
+ if (compact) {
2326
+ if (status.update_available) {
2327
+ console.log(
2328
+ `DeepScientist update available: ${status.current_version} -> ${status.latest_version}`
2329
+ );
2330
+ if (status.can_self_update) {
2331
+ console.log('Run `ds update --yes` to install it.');
2332
+ } else {
2333
+ console.log(`Manual update: ${status.manual_update_command}`);
2334
+ }
2335
+ return;
2336
+ }
2337
+ console.log(`DeepScientist is up to date (${status.current_version}).`);
2338
+ return;
2339
+ }
2340
+
2341
+ console.log('DeepScientist update status');
2342
+ renderKeyValueRows([
2343
+ ['Current', status.current_version],
2344
+ ['Latest', status.latest_version || 'unknown'],
2345
+ ['Available', status.update_available ? 'yes' : 'no'],
2346
+ ['Install mode', status.install_mode],
2347
+ ['Self-update', status.can_self_update ? 'supported' : 'manual-only'],
2348
+ ['Last checked', status.last_checked_at || 'never'],
2349
+ ]);
2350
+ if (status.last_check_error) {
2351
+ console.log('');
2352
+ console.log(`Version check error: ${status.last_check_error}`);
2353
+ }
2354
+ if (!status.can_self_update) {
2355
+ console.log('');
2356
+ console.log(`Manual update command: ${status.manual_update_command}`);
2357
+ if (status.reason) {
2358
+ console.log(status.reason);
2359
+ }
2360
+ }
2361
+ const npmBinary = resolveNpmBinary();
2362
+ if (!npmBinary) {
2363
+ return {
2364
+ ok: false,
2365
+ updated: false,
2366
+ status,
2367
+ message: '`npm` is not available on PATH.',
2368
+ };
2369
+ }
2370
+ }
2371
+
2372
+ function spawnDetachedNode(args, options = {}) {
2373
+ const out = options.logPath ? fs.openSync(options.logPath, 'a') : 'ignore';
2374
+ const child = spawn(process.execPath, args, {
2375
+ cwd: options.cwd || repoRoot,
2376
+ detached: true,
2377
+ stdio: ['ignore', out, out],
2378
+ env: options.env || process.env,
2379
+ });
2380
+ child.unref();
2381
+ return child;
2382
+ }
2383
+
2384
+ async function restartIntoUpdatedLauncher(rawArgs) {
2385
+ const launcherPath = resolveLauncherPath();
2386
+ if (!launcherPath) {
2387
+ throw new Error('Could not resolve the DeepScientist launcher after the update.');
2388
+ }
2389
+ const args = [launcherPath, '--skip-update-check', ...rawArgs.filter((item) => item !== '--skip-update-check')];
2390
+ const child = spawn(process.execPath, args, {
2391
+ cwd: repoRoot,
2392
+ stdio: 'inherit',
2393
+ env: process.env,
2394
+ });
2395
+ await new Promise((resolve, reject) => {
2396
+ child.on('error', reject);
2397
+ child.on('exit', (code) => {
2398
+ process.exit(code ?? 0);
2399
+ resolve();
2400
+ });
2401
+ });
2402
+ }
2403
+
2404
+ async function performSelfUpdate(home, options = {}) {
2405
+ const status = checkForUpdates(home, { force: true });
2406
+ if (!status.update_available) {
2407
+ return {
2408
+ ok: true,
2409
+ updated: false,
2410
+ status,
2411
+ message: `DeepScientist is already on the latest version (${status.current_version}).`,
2412
+ };
2413
+ }
2414
+ if (!status.can_self_update) {
2415
+ return {
2416
+ ok: false,
2417
+ updated: false,
2418
+ status,
2419
+ message: status.reason || `Manual update required: ${status.manual_update_command}`,
2420
+ };
2421
+ }
2422
+
2423
+ const daemonState = readDaemonState(home);
2424
+ const configuredUi = readConfiguredUiAddressFromFile(home, options.host, options.port);
2425
+ const host = options.host || daemonState?.host || configuredUi.host;
2426
+ const port = options.port || daemonState?.port || configuredUi.port;
2427
+ const targetVersion = status.latest_version;
2428
+
2429
+ mergeUpdateState(home, {
2430
+ current_version: status.current_version,
2431
+ latest_version: targetVersion,
2432
+ target_version: targetVersion,
2433
+ busy: true,
2434
+ last_update_started_at: new Date().toISOString(),
2435
+ last_update_result: null,
2436
+ });
2437
+
2438
+ try {
2439
+ if (daemonState?.pid || daemonState?.daemon_id) {
2440
+ await stopDaemon(home);
2441
+ }
2442
+ } catch (error) {
2443
+ mergeUpdateState(home, {
2444
+ busy: false,
2445
+ last_update_finished_at: new Date().toISOString(),
2446
+ last_update_result: {
2447
+ ok: false,
2448
+ target_version: targetVersion,
2449
+ message: error instanceof Error ? error.message : String(error),
2450
+ },
2451
+ });
2452
+ return {
2453
+ ok: false,
2454
+ updated: false,
2455
+ status,
2456
+ message: error instanceof Error ? error.message : String(error),
2457
+ };
2458
+ }
2459
+
2460
+ const installResult = runNpmInstallLatest(home, npmBinary);
2461
+ if (!installResult.ok) {
2462
+ const message = summarizeUpdateFailure(installResult);
2463
+ mergeUpdateState(home, {
2464
+ busy: false,
2465
+ last_update_finished_at: new Date().toISOString(),
2466
+ last_update_result: {
2467
+ ok: false,
2468
+ target_version: targetVersion,
2469
+ message,
2470
+ log_path: installResult.logPath,
2471
+ },
2472
+ });
2473
+ return {
2474
+ ok: false,
2475
+ updated: false,
2476
+ status,
2477
+ message,
2478
+ log_path: installResult.logPath,
2479
+ };
2480
+ }
2481
+
2482
+ const restartDaemon =
2483
+ options.restartDaemon === true
2484
+ || (options.restartDaemon !== false && Boolean(daemonState?.pid || daemonState?.daemon_id));
2485
+ if (restartDaemon) {
2486
+ const launcherPath = resolveLauncherPath();
2487
+ if (!launcherPath) {
2488
+ const message = 'DeepScientist was updated, but the new launcher path could not be resolved for daemon restart.';
2489
+ mergeUpdateState(home, {
2490
+ busy: false,
2491
+ last_update_finished_at: new Date().toISOString(),
2492
+ last_update_result: {
2493
+ ok: false,
2494
+ target_version: targetVersion,
2495
+ message,
2496
+ log_path: installResult.logPath,
2497
+ },
2498
+ });
2499
+ return {
2500
+ ok: false,
2501
+ updated: true,
2502
+ status,
2503
+ message,
2504
+ log_path: installResult.logPath,
2505
+ };
2506
+ }
2507
+ spawnDetachedNode(
2508
+ [
2509
+ launcherPath,
2510
+ '--home',
2511
+ home,
2512
+ '--host',
2513
+ String(host),
2514
+ '--port',
2515
+ String(port),
2516
+ '--daemon-only',
2517
+ '--no-browser',
2518
+ '--skip-update-check',
2519
+ ],
2520
+ {
2521
+ cwd: repoRoot,
2522
+ env: process.env,
2523
+ logPath: path.join(home, 'logs', 'daemon-restart.log'),
2524
+ }
2525
+ );
2526
+ }
2527
+
2528
+ mergeUpdateState(home, {
2529
+ busy: false,
2530
+ current_version: targetVersion,
2531
+ latest_version: targetVersion,
2532
+ target_version: null,
2533
+ last_checked_at: new Date().toISOString(),
2534
+ last_check_error: null,
2535
+ last_update_finished_at: new Date().toISOString(),
2536
+ last_update_result: {
2537
+ ok: true,
2538
+ target_version: targetVersion,
2539
+ message: restartDaemon
2540
+ ? `DeepScientist updated to ${targetVersion}. The daemon is restarting.`
2541
+ : `DeepScientist updated to ${targetVersion}.`,
2542
+ log_path: installResult.logPath,
2543
+ },
2544
+ });
2545
+
2546
+ return {
2547
+ ok: true,
2548
+ updated: true,
2549
+ status: buildUpdateStatus(home),
2550
+ message: restartDaemon
2551
+ ? `DeepScientist updated to ${targetVersion}. The daemon is restarting.`
2552
+ : `DeepScientist updated to ${targetVersion}.`,
2553
+ log_path: installResult.logPath,
2554
+ };
2555
+ }
2556
+
2557
+ async function maybeHandleStartupUpdate(home, rawArgs, options = {}) {
2558
+ if (options.skipUpdateCheck || process.env.DS_SKIP_UPDATE_PROMPT === '1') {
2559
+ return false;
2560
+ }
2561
+ const status = checkForUpdates(home, { force: false });
2562
+ if (!status.update_available) {
2563
+ return false;
2564
+ }
2565
+ if (!status.prompt_recommended) {
2566
+ return false;
2567
+ }
2568
+
2569
+ if (!process.stdin.isTTY || !process.stdout.isTTY) {
2570
+ printUpdateStatus(status, { compact: true });
2571
+ mergeUpdateState(home, {
2572
+ last_prompted_at: new Date().toISOString(),
2573
+ });
2574
+ return false;
2575
+ }
2576
+
2577
+ const action = await promptUpdateAction(status);
2578
+ if (action === 'later') {
2579
+ markUpdateDeferred(home, status.latest_version);
2580
+ return false;
2581
+ }
2582
+ if (action === 'skip') {
2583
+ markUpdateSkipped(home, status.latest_version);
2584
+ return false;
2585
+ }
2586
+ if (action === 'update' && !status.can_self_update) {
2587
+ console.log(`Manual update required: ${status.manual_update_command}`);
2588
+ if (status.reason) {
2589
+ console.log(status.reason);
2590
+ }
2591
+ markUpdateDeferred(home, status.latest_version);
2592
+ return false;
2593
+ }
2594
+ if (action === 'update') {
2595
+ console.log(`Updating DeepScientist ${status.current_version} -> ${status.latest_version} ...`);
2596
+ const result = await performSelfUpdate(home, { restartDaemon: false });
2597
+ if (!result.ok) {
2598
+ console.error(result.message);
2599
+ return false;
2600
+ }
2601
+ console.log(result.message);
2602
+ await restartIntoUpdatedLauncher(rawArgs);
2603
+ return true;
2604
+ }
2605
+ return false;
2606
+ }
2607
+
2608
+ async function startBackgroundUpdateWorker(home, options = {}) {
2609
+ const launcherPath = resolveLauncherPath();
2610
+ if (!launcherPath) {
2611
+ return {
2612
+ ok: false,
2613
+ started: false,
2614
+ message: 'Could not resolve the launcher path for the background update worker.',
2615
+ };
2616
+ }
2617
+ const status = checkForUpdates(home, { force: false });
2618
+ mergeUpdateState(home, {
2619
+ current_version: status.current_version,
2620
+ latest_version: status.latest_version,
2621
+ target_version: status.latest_version,
2622
+ busy: true,
2623
+ last_update_started_at: new Date().toISOString(),
2624
+ last_update_result: null,
2625
+ });
2626
+ const workerArgs = [
2627
+ launcherPath,
2628
+ 'update',
2629
+ '--yes',
2630
+ '--worker',
2631
+ '--home',
2632
+ home,
2633
+ '--host',
2634
+ String(options.host || '0.0.0.0'),
2635
+ '--port',
2636
+ String(options.port || 20999),
2637
+ '--restart-daemon',
2638
+ '--skip-update-check',
2639
+ ];
2640
+ spawnDetachedNode(workerArgs, {
2641
+ cwd: repoRoot,
2642
+ env: process.env,
2643
+ logPath: path.join(home, 'logs', 'update-worker.log'),
2644
+ });
2645
+ return {
2646
+ ok: true,
2647
+ started: true,
2648
+ message: 'DeepScientist update worker started.',
2649
+ status: buildUpdateStatus(home),
2650
+ };
2651
+ }
2652
+
2653
+ async function readConfiguredUiAddress(home, runtimePython, fallbackHost, fallbackPort) {
960
2654
  try {
961
- const result = runPythonCli(venvPython, ['--home', home, 'config', 'show', 'config'], { capture: true, allowFailure: true });
2655
+ const result = runPythonCli(runtimePython, ['--home', home, 'config', 'show', 'config'], { capture: true, allowFailure: true });
962
2656
  const text = result.stdout || '';
963
2657
  const hostMatch = text.match(/^\s*host:\s*["']?([^"'\n]+)["']?\s*$/m);
964
2658
  const portMatch = text.match(/^\s*port:\s*(\d+)\s*$/m);
2659
+ const modeMatch = text.match(/^\s*default_mode:\s*["']?([^"'\n]+)["']?\s*$/m);
2660
+ const autoOpenMatch = text.match(/^\s*auto_open_browser:\s*([^\n]+)\s*$/m);
965
2661
  return {
966
2662
  host: fallbackHost || (hostMatch ? hostMatch[1].trim() : '0.0.0.0'),
967
2663
  port: fallbackPort || (portMatch ? Number(portMatch[1]) : 20999),
2664
+ defaultMode: normalizeMode(modeMatch ? modeMatch[1].trim() : 'web'),
2665
+ autoOpenBrowser: parseBooleanSetting(autoOpenMatch ? autoOpenMatch[1].trim() : true, true),
968
2666
  };
969
2667
  } catch {
970
- return { host: fallbackHost || '0.0.0.0', port: fallbackPort || 20999 };
2668
+ return {
2669
+ host: fallbackHost || '0.0.0.0',
2670
+ port: fallbackPort || 20999,
2671
+ defaultMode: 'web',
2672
+ autoOpenBrowser: true,
2673
+ };
971
2674
  }
972
2675
  }
973
2676
 
974
2677
  function readConfiguredUiAddressFromFile(home, fallbackHost, fallbackPort) {
975
2678
  const configPath = path.join(home, 'config', 'config.yaml');
976
2679
  if (!fs.existsSync(configPath)) {
977
- return { host: fallbackHost || '0.0.0.0', port: fallbackPort || 20999 };
2680
+ return {
2681
+ host: fallbackHost || '0.0.0.0',
2682
+ port: fallbackPort || 20999,
2683
+ defaultMode: 'web',
2684
+ autoOpenBrowser: true,
2685
+ };
978
2686
  }
979
2687
  try {
980
2688
  const text = fs.readFileSync(configPath, 'utf8');
981
2689
  const hostMatch = text.match(/^\s*host:\s*["']?([^"'\n]+)["']?\s*$/m);
982
2690
  const portMatch = text.match(/^\s*port:\s*(\d+)\s*$/m);
2691
+ const modeMatch = text.match(/^\s*default_mode:\s*["']?([^"'\n]+)["']?\s*$/m);
2692
+ const autoOpenMatch = text.match(/^\s*auto_open_browser:\s*([^\n]+)\s*$/m);
983
2693
  return {
984
2694
  host: fallbackHost || (hostMatch ? hostMatch[1].trim() : '0.0.0.0'),
985
2695
  port: fallbackPort || (portMatch ? Number(portMatch[1]) : 20999),
2696
+ defaultMode: normalizeMode(modeMatch ? modeMatch[1].trim() : 'web'),
2697
+ autoOpenBrowser: parseBooleanSetting(autoOpenMatch ? autoOpenMatch[1].trim() : true, true),
986
2698
  };
987
2699
  } catch {
988
- return { host: fallbackHost || '0.0.0.0', port: fallbackPort || 20999 };
2700
+ return {
2701
+ host: fallbackHost || '0.0.0.0',
2702
+ port: fallbackPort || 20999,
2703
+ defaultMode: 'web',
2704
+ autoOpenBrowser: true,
2705
+ };
989
2706
  }
990
2707
  }
991
2708
 
992
- async function startDaemon(home, venvPython, host, port) {
2709
+ async function startDaemon(home, runtimePython, host, port) {
993
2710
  const browserUrl = browserUiUrl(host, port);
994
2711
  const daemonBindUrl = bindUiUrl(host, port);
995
2712
  const state = readDaemonState(home);
@@ -1013,10 +2730,10 @@ async function startDaemon(home, venvPython, host, port) {
1013
2730
  removeDaemonState(home);
1014
2731
  }
1015
2732
 
1016
- const bootstrapState = readCodexBootstrapState(home, venvPython);
2733
+ const bootstrapState = readCodexBootstrapState(home, runtimePython);
1017
2734
  if (!bootstrapState.codex_ready) {
1018
2735
  console.log('Codex is not marked ready yet. Running startup probe...');
1019
- const probe = probeCodexBootstrap(home, venvPython);
2736
+ const probe = probeCodexBootstrap(home, runtimePython);
1020
2737
  if (!probe || probe.ok !== true) {
1021
2738
  throw createCodexPreflightError(home, probe);
1022
2739
  }
@@ -1029,7 +2746,7 @@ async function startDaemon(home, venvPython, host, port) {
1029
2746
  const out = fs.openSync(logPath, 'a');
1030
2747
  const daemonId = crypto.randomUUID();
1031
2748
  const child = spawn(
1032
- venvPython,
2749
+ runtimePython,
1033
2750
  ['-m', 'deepscientist.cli', '--home', home, 'daemon', '--host', host, '--port', String(port)],
1034
2751
  {
1035
2752
  cwd: repoRoot,
@@ -1037,11 +2754,11 @@ async function startDaemon(home, venvPython, host, port) {
1037
2754
  stdio: ['ignore', out, out],
1038
2755
  env: {
1039
2756
  ...process.env,
2757
+ DEEPSCIENTIST_REPO_ROOT: repoRoot,
2758
+ DEEPSCIENTIST_NODE_BINARY: process.execPath,
2759
+ DEEPSCIENTIST_LAUNCHER_PATH: path.join(repoRoot, 'bin', 'ds.js'),
1040
2760
  DS_DAEMON_ID: daemonId,
1041
2761
  DS_DAEMON_MANAGED_BY: 'ds-launcher',
1042
- PYTHONPATH: process.env.PYTHONPATH
1043
- ? `${srcPath}${path.delimiter}${process.env.PYTHONPATH}`
1044
- : srcPath,
1045
2762
  },
1046
2763
  }
1047
2764
  );
@@ -1080,38 +2797,42 @@ async function startDaemon(home, venvPython, host, port) {
1080
2797
  }
1081
2798
 
1082
2799
  function openBrowser(url) {
1083
- try {
1084
- if (process.platform === 'darwin') {
1085
- const child = spawn('open', [url], { detached: true, stdio: 'ignore' });
1086
- child.on('error', () => {
1087
- console.log(`Open this URL in your browser: ${url}`);
1088
- });
2800
+ const spawnDetached = (command, args) => {
2801
+ try {
2802
+ const child = spawn(command, args, { detached: true, stdio: 'ignore' });
1089
2803
  child.unref();
1090
2804
  return true;
2805
+ } catch {
2806
+ return false;
1091
2807
  }
1092
- if (process.platform === 'win32') {
1093
- const child = spawn('cmd', ['/c', 'start', '', url], { detached: true, stdio: 'ignore' });
1094
- child.on('error', () => {
1095
- console.log(`Open this URL in your browser: ${url}`);
1096
- });
1097
- child.unref();
1098
- return true;
2808
+ };
2809
+
2810
+ if (process.platform === 'darwin') {
2811
+ const opener = resolveExecutableOnPath('open');
2812
+ return opener ? spawnDetached(opener, [url]) : false;
2813
+ }
2814
+ if (process.platform === 'win32') {
2815
+ return spawnDetached('cmd', ['/c', 'start', '', url]);
2816
+ }
2817
+
2818
+ const commands = [
2819
+ { command: 'xdg-open', args: [url] },
2820
+ { command: 'gio', args: ['open', url] },
2821
+ { command: 'sensible-browser', args: [url] },
2822
+ { command: 'gnome-open', args: [url] },
2823
+ { command: 'kde-open', args: [url] },
2824
+ { command: 'kde-open5', args: [url] },
2825
+ ];
2826
+ for (const candidate of commands) {
2827
+ const resolved = resolveExecutableOnPath(candidate.command);
2828
+ if (!resolved) {
2829
+ continue;
1099
2830
  }
1100
- const probe = spawnSync('xdg-open', ['--help'], { stdio: 'ignore' });
1101
- if (probe.error || probe.status !== 0) {
1102
- console.log(`Open this URL in your browser: ${url}`);
1103
- return false;
2831
+ if (spawnDetached(resolved, candidate.args)) {
2832
+ return true;
1104
2833
  }
1105
- const child = spawn('xdg-open', [url], { detached: true, stdio: 'ignore' });
1106
- child.on('error', () => {
1107
- console.log(`Open this URL in your browser: ${url}`);
1108
- });
1109
- child.unref();
1110
- return true;
1111
- } catch {
1112
- console.log(`Open this URL in your browser: ${url}`);
1113
- return false;
1114
2834
  }
2835
+ return false;
1115
2836
  }
1116
2837
 
1117
2838
  function handleCodexPreflightFailure(error) {
@@ -1131,7 +2852,7 @@ function handleCodexPreflightFailure(error) {
1131
2852
  return true;
1132
2853
  }
1133
2854
 
1134
- function launchTui(url, questId, home, venvPython) {
2855
+ function launchTui(url, questId, home, runtimePython) {
1135
2856
  const entry = ensureNodeBundle('src/tui', 'dist/index.js');
1136
2857
  const args = [entry, '--base-url', url];
1137
2858
  if (questId) {
@@ -1143,8 +2864,8 @@ function launchTui(url, questId, home, venvPython) {
1143
2864
  env: {
1144
2865
  ...process.env,
1145
2866
  DEEPSCIENTIST_TUI_HOME: home,
1146
- DEEPSCIENTIST_TUI_PYTHON: venvPython,
1147
- DEEPSCIENTIST_VENV_PYTHON: venvPython,
2867
+ DEEPSCIENTIST_TUI_PYTHON: runtimePython,
2868
+ DEEPSCIENTIST_RUNTIME_PYTHON: runtimePython,
1148
2869
  },
1149
2870
  });
1150
2871
  child.on('exit', (code) => {
@@ -1152,6 +2873,129 @@ function launchTui(url, questId, home, venvPython) {
1152
2873
  });
1153
2874
  }
1154
2875
 
2876
+ async function updateMain(rawArgs) {
2877
+ const options = parseUpdateArgs(rawArgs);
2878
+ if (!options) {
2879
+ printUpdateHelp();
2880
+ process.exit(1);
2881
+ }
2882
+ if (options.help) {
2883
+ printUpdateHelp();
2884
+ process.exit(0);
2885
+ }
2886
+
2887
+ const home = options.home || resolveHome(rawArgs);
2888
+ ensureDir(home);
2889
+
2890
+ if (options.background && options.yes && !options.worker) {
2891
+ const payload = await startBackgroundUpdateWorker(home, {
2892
+ host: options.host,
2893
+ port: options.port,
2894
+ });
2895
+ if (options.json) {
2896
+ console.log(JSON.stringify(payload, null, 2));
2897
+ } else {
2898
+ console.log(payload.message);
2899
+ }
2900
+ process.exit(payload.ok ? 0 : 1);
2901
+ }
2902
+
2903
+ const status = checkForUpdates(home, { force: options.forceCheck || options.check || options.yes || options.worker });
2904
+
2905
+ if (options.remindLater) {
2906
+ const payload = markUpdateDeferred(home, status.latest_version);
2907
+ if (options.json) {
2908
+ console.log(JSON.stringify(payload, null, 2));
2909
+ } else {
2910
+ console.log(`DeepScientist will remind you later about ${payload.latest_version || 'the next release'}.`);
2911
+ }
2912
+ process.exit(0);
2913
+ }
2914
+
2915
+ if (options.skipVersion) {
2916
+ const payload = markUpdateSkipped(home, status.latest_version);
2917
+ if (options.json) {
2918
+ console.log(JSON.stringify(payload, null, 2));
2919
+ } else {
2920
+ console.log(`DeepScientist will stop prompting for ${payload.last_skipped_version || payload.latest_version || 'this release'}.`);
2921
+ }
2922
+ process.exit(0);
2923
+ }
2924
+
2925
+ if (options.worker) {
2926
+ const payload = await performSelfUpdate(home, {
2927
+ host: options.host,
2928
+ port: options.port,
2929
+ restartDaemon: options.restartDaemon,
2930
+ });
2931
+ if (options.json) {
2932
+ console.log(JSON.stringify(payload, null, 2));
2933
+ } else {
2934
+ console.log(payload.message);
2935
+ }
2936
+ process.exit(payload.ok ? 0 : 1);
2937
+ }
2938
+
2939
+ if (options.yes) {
2940
+ const payload = await performSelfUpdate(home, {
2941
+ host: options.host,
2942
+ port: options.port,
2943
+ restartDaemon: options.restartDaemon,
2944
+ });
2945
+ if (options.json) {
2946
+ console.log(JSON.stringify(payload, null, 2));
2947
+ } else {
2948
+ console.log(payload.message);
2949
+ }
2950
+ process.exit(payload.ok ? 0 : 1);
2951
+ }
2952
+
2953
+ if (options.check || options.json) {
2954
+ if (options.json) {
2955
+ console.log(JSON.stringify(status, null, 2));
2956
+ } else {
2957
+ printUpdateStatus(status);
2958
+ }
2959
+ process.exit(0);
2960
+ }
2961
+
2962
+ if (!status.update_available) {
2963
+ printUpdateStatus(status, { compact: true });
2964
+ process.exit(0);
2965
+ }
2966
+
2967
+ if (!process.stdin.isTTY || !process.stdout.isTTY) {
2968
+ printUpdateStatus(status, { compact: true });
2969
+ process.exit(0);
2970
+ }
2971
+
2972
+ const action = await promptUpdateAction(status);
2973
+ if (action === 'later') {
2974
+ markUpdateDeferred(home, status.latest_version);
2975
+ console.log('Update reminder deferred.');
2976
+ process.exit(0);
2977
+ }
2978
+ if (action === 'skip') {
2979
+ markUpdateSkipped(home, status.latest_version);
2980
+ console.log(`Skipped ${status.latest_version}.`);
2981
+ process.exit(0);
2982
+ }
2983
+ if (!status.can_self_update) {
2984
+ console.log(`Manual update command: ${status.manual_update_command}`);
2985
+ if (status.reason) {
2986
+ console.log(status.reason);
2987
+ }
2988
+ process.exit(0);
2989
+ }
2990
+ const payload = await performSelfUpdate(home, {
2991
+ host: options.host,
2992
+ port: options.port,
2993
+ restartDaemon: options.restartDaemon,
2994
+ });
2995
+ console.log(payload.message);
2996
+ process.exit(payload.ok ? 0 : 1);
2997
+ }
2998
+
1155
2999
  async function launcherMain(rawArgs) {
1156
3000
  const options = parseLauncherArgs(rawArgs);
1157
3001
  if (!options) {
@@ -1195,11 +3039,23 @@ async function launcherMain(rawArgs) {
1195
3039
  process.exit(healthy && (!state || identityMatch) ? 0 : 1);
1196
3040
  }
1197
3041
 
1198
- const venvPython = ensurePythonRuntime(home);
1199
- ensureInitialized(home, venvPython);
3042
+ const pythonRuntime = ensurePythonRuntime(home);
3043
+ const runtimePython = pythonRuntime.runtimePython;
3044
+ ensureInitialized(home, runtimePython);
3045
+ if (await maybeHandleStartupUpdate(home, rawArgs, options)) {
3046
+ return true;
3047
+ }
1200
3048
  maybePrintOptionalLatexNotice(home);
1201
3049
 
1202
- const { host, port } = await readConfiguredUiAddress(home, venvPython, options.host, options.port);
3050
+ const configuredUi = await readConfiguredUiAddress(home, runtimePython, options.host, options.port);
3051
+ const host = configuredUi.host;
3052
+ const port = configuredUi.port;
3053
+ const mode = normalizeMode(options.mode ?? 'web');
3054
+ const shouldOpenBrowser = options.daemonOnly
3055
+ ? false
3056
+ : options.openBrowser === null
3057
+ ? configuredUi.autoOpenBrowser !== false && mode !== 'tui'
3058
+ : options.openBrowser;
1203
3059
  if (options.restart) {
1204
3060
  await stopDaemon(home);
1205
3061
  }
@@ -1207,35 +3063,40 @@ async function launcherMain(rawArgs) {
1207
3063
  step(4, 4, 'Starting local daemon and UI surfaces');
1208
3064
  let started;
1209
3065
  try {
1210
- started = await startDaemon(home, venvPython, host, port);
3066
+ started = await startDaemon(home, runtimePython, host, port);
1211
3067
  } catch (error) {
1212
3068
  if (handleCodexPreflightFailure(error)) return true;
1213
3069
  throw error;
1214
3070
  }
1215
- console.log('');
1216
- console.log('DeepScientist is ready.');
1217
- console.log(`Web: ${started.url}`);
1218
- console.log(`Daemon bind: ${started.bindUrl}`);
1219
- console.log('The web workspace opens automatically when supported.');
1220
- console.log('Use Ctrl+O in TUI to open the web workspace again.');
1221
- console.log('');
3071
+ const browserOpened = shouldOpenBrowser ? openBrowser(started.url) : false;
3072
+ printLaunchCard({
3073
+ url: started.url,
3074
+ bindUrl: started.bindUrl,
3075
+ mode,
3076
+ autoOpenRequested: shouldOpenBrowser,
3077
+ browserOpened,
3078
+ daemonOnly: options.daemonOnly,
3079
+ home,
3080
+ pythonSelection: pythonRuntime.runtimeProbe,
3081
+ });
1222
3082
 
1223
- if (options.mode === 'web' || options.mode === 'both' || options.openBrowser) {
1224
- openBrowser(started.url);
1225
- }
1226
3083
  if (options.daemonOnly) {
1227
3084
  process.exit(0);
1228
3085
  }
1229
- if (options.mode === 'web') {
3086
+ if (mode === 'web') {
1230
3087
  process.exit(0);
1231
3088
  }
1232
- launchTui(started.url, options.questId, home, venvPython);
3089
+ launchTui(started.url, options.questId, home, runtimePython);
1233
3090
  return true;
1234
3091
  }
1235
3092
 
1236
3093
  async function main() {
1237
3094
  const args = process.argv.slice(2);
1238
3095
  const positional = findFirstPositionalArg(args);
3096
+ if (positional && positional.value === 'update') {
3097
+ await updateMain(args);
3098
+ return;
3099
+ }
1239
3100
  if (args.length === 0 || args[0] === 'ui' || (!positional && args[0]?.startsWith('--'))) {
1240
3101
  await launcherMain(args);
1241
3102
  return;
@@ -1246,15 +3107,16 @@ async function main() {
1246
3107
  }
1247
3108
  if (positional && pythonCommands.has(positional.value)) {
1248
3109
  const home = resolveHome(args);
1249
- const venvPython = ensurePythonRuntime(home);
3110
+ const pythonRuntime = ensurePythonRuntime(home);
3111
+ const runtimePython = pythonRuntime.runtimePython;
1250
3112
  if (positional.value === 'run' || positional.value === 'daemon') {
1251
3113
  maybePrintOptionalLatexNotice(home);
1252
3114
  }
1253
3115
  if (positional.value === 'run' || positional.value === 'daemon') {
1254
- const bootstrapState = readCodexBootstrapState(home, venvPython);
3116
+ const bootstrapState = readCodexBootstrapState(home, runtimePython);
1255
3117
  if (!bootstrapState.codex_ready) {
1256
3118
  try {
1257
- const probe = probeCodexBootstrap(home, venvPython);
3119
+ const probe = probeCodexBootstrap(home, runtimePython);
1258
3120
  if (!probe || probe.ok !== true) {
1259
3121
  throw createCodexPreflightError(home, probe);
1260
3122
  }
@@ -1264,14 +3126,34 @@ async function main() {
1264
3126
  }
1265
3127
  }
1266
3128
  }
1267
- const result = runPythonCli(venvPython, normalizePythonCliArgs(args, home), { allowFailure: true });
3129
+ const result = runPythonCli(runtimePython, normalizePythonCliArgs(args, home), { allowFailure: true });
1268
3130
  process.exit(result.status ?? 0);
1269
3131
  return;
1270
3132
  }
1271
3133
  await launcherMain(args);
1272
3134
  }
1273
3135
 
1274
- main().catch((error) => {
1275
- console.error(error instanceof Error ? error.message : String(error));
1276
- process.exit(1);
1277
- });
3136
+ module.exports = {
3137
+ __internal: {
3138
+ minimumPythonRequest,
3139
+ createPythonRuntimePlan,
3140
+ buildUvRuntimeEnv,
3141
+ runtimePythonEnvPath,
3142
+ runtimePythonPath,
3143
+ runtimeUvBinaryPath,
3144
+ legacyVenvRootPath,
3145
+ resolveUvBinary,
3146
+ resolveHome,
3147
+ useEditableProjectInstall,
3148
+ compareVersions,
3149
+ detectInstallMode,
3150
+ buildUpdateStatus,
3151
+ },
3152
+ };
3153
+
3154
+ if (require.main === module) {
3155
+ main().catch((error) => {
3156
+ console.error(error instanceof Error ? error.message : String(error));
3157
+ process.exit(1);
3158
+ });
3159
+ }