@researai/deepscientist 1.5.1 → 1.5.3

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 (116) hide show
  1. package/README.md +69 -1
  2. package/bin/ds.js +2239 -153
  3. package/docs/en/00_QUICK_START.md +60 -20
  4. package/docs/en/01_SETTINGS_REFERENCE.md +20 -20
  5. package/docs/en/02_START_RESEARCH_GUIDE.md +11 -11
  6. package/docs/en/03_QQ_CONNECTOR_GUIDE.md +10 -10
  7. package/docs/en/05_TUI_GUIDE.md +1 -1
  8. package/docs/en/09_DOCTOR.md +48 -4
  9. package/docs/en/90_ARCHITECTURE.md +4 -2
  10. package/docs/zh/00_QUICK_START.md +60 -20
  11. package/docs/zh/01_SETTINGS_REFERENCE.md +21 -21
  12. package/docs/zh/02_START_RESEARCH_GUIDE.md +19 -19
  13. package/docs/zh/03_QQ_CONNECTOR_GUIDE.md +10 -10
  14. package/docs/zh/05_TUI_GUIDE.md +1 -1
  15. package/docs/zh/09_DOCTOR.md +46 -4
  16. package/install.sh +125 -8
  17. package/package.json +2 -1
  18. package/pyproject.toml +1 -1
  19. package/src/deepscientist/__init__.py +6 -1
  20. package/src/deepscientist/artifact/service.py +553 -26
  21. package/src/deepscientist/bash_exec/monitor.py +23 -4
  22. package/src/deepscientist/bash_exec/runtime.py +3 -0
  23. package/src/deepscientist/bash_exec/service.py +132 -4
  24. package/src/deepscientist/bridges/base.py +10 -19
  25. package/src/deepscientist/channels/discord_gateway.py +25 -2
  26. package/src/deepscientist/channels/feishu_long_connection.py +41 -3
  27. package/src/deepscientist/channels/qq.py +524 -64
  28. package/src/deepscientist/channels/qq_gateway.py +22 -3
  29. package/src/deepscientist/channels/relay.py +429 -90
  30. package/src/deepscientist/channels/slack_socket.py +29 -5
  31. package/src/deepscientist/channels/telegram_polling.py +25 -2
  32. package/src/deepscientist/channels/whatsapp_local_session.py +32 -4
  33. package/src/deepscientist/cli.py +27 -0
  34. package/src/deepscientist/config/models.py +6 -40
  35. package/src/deepscientist/config/service.py +165 -156
  36. package/src/deepscientist/connector_profiles.py +346 -0
  37. package/src/deepscientist/connector_runtime.py +88 -43
  38. package/src/deepscientist/daemon/api/handlers.py +65 -11
  39. package/src/deepscientist/daemon/api/router.py +4 -2
  40. package/src/deepscientist/daemon/app.py +772 -219
  41. package/src/deepscientist/doctor.py +69 -2
  42. package/src/deepscientist/gitops/diff.py +3 -0
  43. package/src/deepscientist/home.py +25 -2
  44. package/src/deepscientist/mcp/context.py +3 -1
  45. package/src/deepscientist/mcp/server.py +66 -7
  46. package/src/deepscientist/migration.py +114 -0
  47. package/src/deepscientist/prompts/builder.py +71 -3
  48. package/src/deepscientist/qq_profiles.py +186 -0
  49. package/src/deepscientist/quest/layout.py +1 -0
  50. package/src/deepscientist/quest/service.py +70 -12
  51. package/src/deepscientist/quest/stage_views.py +46 -0
  52. package/src/deepscientist/runners/codex.py +2 -0
  53. package/src/deepscientist/shared.py +44 -17
  54. package/src/prompts/connectors/lingzhu.md +3 -0
  55. package/src/prompts/connectors/qq.md +42 -2
  56. package/src/prompts/system.md +123 -10
  57. package/src/skills/analysis-campaign/SKILL.md +35 -6
  58. package/src/skills/baseline/SKILL.md +73 -32
  59. package/src/skills/decision/SKILL.md +4 -3
  60. package/src/skills/experiment/SKILL.md +28 -6
  61. package/src/skills/finalize/SKILL.md +5 -2
  62. package/src/skills/idea/SKILL.md +2 -2
  63. package/src/skills/intake-audit/SKILL.md +2 -2
  64. package/src/skills/rebuttal/SKILL.md +4 -2
  65. package/src/skills/review/SKILL.md +4 -2
  66. package/src/skills/scout/SKILL.md +2 -2
  67. package/src/skills/write/SKILL.md +2 -2
  68. package/src/tui/package.json +1 -1
  69. package/src/ui/dist/assets/{AiManusChatView-w5lF2Ttt.js → AiManusChatView-qzChi9uh.js} +67 -94
  70. package/src/ui/dist/assets/{AnalysisPlugin-DJOED79I.js → AnalysisPlugin-CcC_-UqN.js} +1 -1
  71. package/src/ui/dist/assets/{AutoFigurePlugin-DaG61Y0M.js → AutoFigurePlugin-DD8LkJLe.js} +5 -5
  72. package/src/ui/dist/assets/{CliPlugin-CV4LqUB_.js → CliPlugin-DJJFfVmW.js} +17 -110
  73. package/src/ui/dist/assets/{CodeEditorPlugin-DylfAea4.js → CodeEditorPlugin-CrjkHNLh.js} +8 -8
  74. package/src/ui/dist/assets/{CodeViewerPlugin-F7saY0LM.js → CodeViewerPlugin-obnD6G5R.js} +5 -5
  75. package/src/ui/dist/assets/{DocViewerPlugin-COP0c7jf.js → DocViewerPlugin-DB9SUQVd.js} +3 -3
  76. package/src/ui/dist/assets/{GitDiffViewerPlugin-CAS05pT9.js → GitDiffViewerPlugin-DZLlNlD2.js} +1 -1
  77. package/src/ui/dist/assets/{ImageViewerPlugin-Bco1CN_w.js → ImageViewerPlugin-BGwfDZ0Y.js} +5 -5
  78. package/src/ui/dist/assets/{LabCopilotPanel-CvMlCD99.js → LabCopilotPanel-dfLptQcR.js} +10 -10
  79. package/src/ui/dist/assets/{LabPlugin-BYankkE4.js → LabPlugin-CeGjAl3A.js} +1 -1
  80. package/src/ui/dist/assets/{LatexPlugin-LDSMR-t-.js → LatexPlugin-BBJ7kd1V.js} +7 -7
  81. package/src/ui/dist/assets/{MarkdownViewerPlugin-B7o80jgm.js → MarkdownViewerPlugin-DKZi7BcB.js} +4 -4
  82. package/src/ui/dist/assets/{MarketplacePlugin-CM6ZOcpC.js → MarketplacePlugin-C_k-9jD0.js} +3 -3
  83. package/src/ui/dist/assets/{NotebookEditor-Dc61cXmK.js → NotebookEditor-4R88_BMO.js} +1 -1
  84. package/src/ui/dist/assets/{PdfLoader-DWowuQwx.js → PdfLoader-DwEFQLrw.js} +1 -1
  85. package/src/ui/dist/assets/{PdfMarkdownPlugin-BsJM1q_a.js → PdfMarkdownPlugin-D-jdsqF8.js} +3 -3
  86. package/src/ui/dist/assets/{PdfViewerPlugin-DB2eEEFQ.js → PdfViewerPlugin-CmeBGDY0.js} +10 -10
  87. package/src/ui/dist/assets/{SearchPlugin-CraThSvt.js → SearchPlugin-Dlz2WKJ4.js} +1 -1
  88. package/src/ui/dist/assets/{Stepper-CgocRTPq.js → Stepper-ClOgzWM3.js} +1 -1
  89. package/src/ui/dist/assets/{TextViewerPlugin-B1JGhKtd.js → TextViewerPlugin-DDQWxibk.js} +4 -4
  90. package/src/ui/dist/assets/{VNCViewer-CclFC7FM.js → VNCViewer-CJXT0Nm8.js} +9 -9
  91. package/src/ui/dist/assets/{bibtex-D3IKsMl7.js → bibtex-DLr4Rtk4.js} +1 -1
  92. package/src/ui/dist/assets/{code-BP37Xx0p.js → code-DgKK408Y.js} +1 -1
  93. package/src/ui/dist/assets/{file-content-BAJSu-9r.js → file-content-6HBqQnvQ.js} +1 -1
  94. package/src/ui/dist/assets/{file-diff-panel-DUGeCTuy.js → file-diff-panel-Dhu0TbBM.js} +1 -1
  95. package/src/ui/dist/assets/{file-socket-CXc1Ojf7.js → file-socket-CP3iwVZG.js} +1 -1
  96. package/src/ui/dist/assets/{file-utils-2J21jt7M.js → file-utils-BsS-Aw68.js} +1 -1
  97. package/src/ui/dist/assets/{image-CMMmgvcn.js → image-ByeK-Zcv.js} +1 -1
  98. package/src/ui/dist/assets/{index-DmwmJmbW.js → index-BLjo5--a.js} +33610 -31016
  99. package/src/ui/dist/assets/{index-CWgMgpow.js → index-BdsE0uRz.js} +11 -11
  100. package/src/ui/dist/assets/{index-s7aHnNQ4.js → index-C-eX-N6A.js} +1 -1
  101. package/src/ui/dist/assets/{index-KGt-z-dD.css → index-CuQhlrR-.css} +2747 -2
  102. package/src/ui/dist/assets/{index-BaVumsQT.js → index-DyremSIv.js} +2 -2
  103. package/src/ui/dist/assets/{message-square-CQRfX0Am.js → message-square-DnagiLnc.js} +1 -1
  104. package/src/ui/dist/assets/{monaco-B4TbdsrF.js → monaco-4kBFeprs.js} +1 -1
  105. package/src/ui/dist/assets/{popover-B8Rokodk.js → popover-hRCXZzs2.js} +1 -1
  106. package/src/ui/dist/assets/{project-sync-D_i96KH4.js → project-sync-O_85YuP6.js} +1 -1
  107. package/src/ui/dist/assets/{sigma-D12PnzCN.js → sigma-DvKopSnL.js} +1 -1
  108. package/src/ui/dist/assets/{tooltip-B6YrI4aJ.js → tooltip-BmlPc6kc.js} +1 -1
  109. package/src/ui/dist/assets/{trash-Bc8jGp0V.js → trash-n-UvdZFR.js} +1 -1
  110. package/src/ui/dist/assets/{useCliAccess-mXVCYSZ-.js → useCliAccess-WDd3_wIh.js} +1 -1
  111. package/src/ui/dist/assets/{useFileDiffOverlay-Bg6b9H9K.js → useFileDiffOverlay-rXLIL2NF.js} +1 -1
  112. package/src/ui/dist/assets/{wrap-text-Drh5GEnL.js → wrap-text-qIYQ4a_W.js} +1 -1
  113. package/src/ui/dist/assets/{zoom-out-CJj9DZLn.js → zoom-out-fZXCEFsy.js} +1 -1
  114. package/src/ui/dist/index.html +2 -2
  115. package/uv.lock +1155 -0
  116. package/src/ui/dist/assets/LabPlugin-D9jVIo0A.css +0 -2698
package/bin/ds.js CHANGED
@@ -3,13 +3,17 @@ 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);
16
+ const launcherWrapperCommands = ['ds', 'ds-cli', 'research', 'resear'];
13
17
  const pythonCommands = new Set([
14
18
  'init',
15
19
  'new',
@@ -29,6 +33,9 @@ const pythonCommands = new Set([
29
33
  'latex',
30
34
  'config',
31
35
  ]);
36
+ const UPDATE_PACKAGE_NAME = String(packageJson.name || '@researai/deepscientist').trim() || '@researai/deepscientist';
37
+ const UPDATE_CHECK_TTL_MS = 12 * 60 * 60 * 1000;
38
+ const UPDATE_PROMPT_TTL_MS = 12 * 60 * 60 * 1000;
32
39
 
33
40
  const optionsWithValues = new Set(['--home', '--host', '--port', '--quest-id', '--mode']);
34
41
 
@@ -37,14 +44,51 @@ function printLauncherHelp() {
37
44
 
38
45
  Usage:
39
46
  ds
47
+ ds update
48
+ ds update --check
49
+ ds update --yes
50
+ ds migrate /data/DeepScientist
51
+ ds --hero
52
+ ds --hero doctor
40
53
  ds --tui
41
54
  ds --both
55
+ ds --host 0.0.0.0 --port 21000
42
56
  ds --stop
43
57
  ds --restart
58
+ ds --status
44
59
  ds doctor
45
60
  ds latex status
46
61
  ds --home ~/DeepScientist --port 20999
47
62
 
63
+ Launcher flags:
64
+ --host <host> Bind host for the local web daemon
65
+ --port <port> Bind port for the local web daemon
66
+ --tui Start the terminal workspace only
67
+ --both Start web + terminal workspace together
68
+ --no-browser Do not auto-open the browser
69
+ --daemon-only Start the managed daemon and exit
70
+ --status Print managed daemon health as JSON
71
+ --stop Stop the managed daemon
72
+ --restart Restart the managed daemon
73
+ --home <path> Use a custom DeepScientist home
74
+ --hero Use the current working directory as DeepScientist home
75
+ --quest-id <id> Open the TUI on one quest directly
76
+
77
+ Update:
78
+ ds update Check the npm package version and offer update actions
79
+ ds update --check Print structured update status
80
+ ds update --yes Install the latest npm release immediately
81
+
82
+ Migration:
83
+ ds migrate <target> Move the DeepScientist home/install root to a new absolute path
84
+ ds migrate <target> --yes --restart
85
+
86
+ Runtime:
87
+ DeepScientist uses uv to manage a locked local Python runtime.
88
+ If uv is missing, ds bootstraps a local copy under the DeepScientist home automatically.
89
+ If an active conda environment provides Python ${requiredPythonSpec}, ds prefers it.
90
+ Otherwise uv provisions a managed Python under the DeepScientist home automatically.
91
+
48
92
  Advanced Python CLI:
49
93
  ds init
50
94
  ds new "reproduce baseline and test one stronger idea"
@@ -58,14 +102,306 @@ function ensureDir(targetPath) {
58
102
  fs.mkdirSync(targetPath, { recursive: true });
59
103
  }
60
104
 
105
+ function expandUserPath(rawPath) {
106
+ const normalized = String(rawPath || '').trim();
107
+ if (!normalized) {
108
+ return normalized;
109
+ }
110
+ if (normalized === '~') {
111
+ return os.homedir();
112
+ }
113
+ if (normalized.startsWith(`~${path.sep}`) || normalized.startsWith('~/')) {
114
+ return path.join(os.homedir(), normalized.slice(2));
115
+ }
116
+ return normalized;
117
+ }
118
+
119
+ function updateStatePath(home) {
120
+ return path.join(home, 'runtime', 'update-state.json');
121
+ }
122
+
123
+ function readUpdateState(home) {
124
+ return readJsonFile(updateStatePath(home)) || {};
125
+ }
126
+
127
+ function writeUpdateState(home, payload) {
128
+ const statePath = updateStatePath(home);
129
+ ensureDir(path.dirname(statePath));
130
+ fs.writeFileSync(statePath, `${JSON.stringify(payload, null, 2)}\n`, 'utf8');
131
+ }
132
+
133
+ function mergeUpdateState(home, patch) {
134
+ const current = readUpdateState(home);
135
+ const next = {
136
+ ...current,
137
+ ...patch,
138
+ };
139
+ writeUpdateState(home, next);
140
+ return next;
141
+ }
142
+
143
+ function parseTimestamp(value) {
144
+ if (!value) {
145
+ return null;
146
+ }
147
+ const parsed = Date.parse(String(value));
148
+ return Number.isFinite(parsed) ? parsed : null;
149
+ }
150
+
151
+ function isExpired(value, ttlMs) {
152
+ const parsed = parseTimestamp(value);
153
+ if (parsed === null) {
154
+ return true;
155
+ }
156
+ return Date.now() - parsed > ttlMs;
157
+ }
158
+
159
+ function normalizeVersion(value) {
160
+ return String(value || '')
161
+ .trim()
162
+ .replace(/^v/i, '');
163
+ }
164
+
165
+ function compareVersions(left, right) {
166
+ const leftParts = normalizeVersion(left).split('.').map((item) => Number.parseInt(item, 10) || 0);
167
+ const rightParts = normalizeVersion(right).split('.').map((item) => Number.parseInt(item, 10) || 0);
168
+ const length = Math.max(leftParts.length, rightParts.length, 3);
169
+ for (let index = 0; index < length; index += 1) {
170
+ const leftValue = leftParts[index] || 0;
171
+ const rightValue = rightParts[index] || 0;
172
+ if (leftValue > rightValue) {
173
+ return 1;
174
+ }
175
+ if (leftValue < rightValue) {
176
+ return -1;
177
+ }
178
+ }
179
+ return 0;
180
+ }
181
+
182
+ function detectInstallMode(rootPath = repoRoot) {
183
+ const normalized = String(rootPath || '');
184
+ return normalized.includes(`${path.sep}node_modules${path.sep}`) ? 'npm-package' : 'source-checkout';
185
+ }
186
+
187
+ function updateManualCommand(installMode) {
188
+ if (installMode === 'npm-package') {
189
+ return `npm install -g ${UPDATE_PACKAGE_NAME}@latest`;
190
+ }
191
+ return 'git pull && bash install.sh';
192
+ }
193
+
194
+ function updateSupportSummary(installMode, npmBinary, launcherPath) {
195
+ if (!npmBinary) {
196
+ return {
197
+ canCheck: false,
198
+ canSelfUpdate: false,
199
+ reason: '`npm` is not available on PATH.',
200
+ };
201
+ }
202
+ if (installMode !== 'npm-package') {
203
+ return {
204
+ canCheck: true,
205
+ canSelfUpdate: false,
206
+ reason: 'This DeepScientist installation comes from a source checkout and should be updated from Git.',
207
+ };
208
+ }
209
+ if (!launcherPath || !fs.existsSync(launcherPath)) {
210
+ return {
211
+ canCheck: true,
212
+ canSelfUpdate: false,
213
+ reason: 'The launcher entrypoint could not be resolved.',
214
+ };
215
+ }
216
+ return {
217
+ canCheck: true,
218
+ canSelfUpdate: true,
219
+ reason: null,
220
+ };
221
+ }
222
+
223
+ function resolveNpmBinary() {
224
+ return resolveExecutableOnPath(process.platform === 'win32' ? 'npm.cmd' : 'npm') || resolveExecutableOnPath('npm');
225
+ }
226
+
227
+ function resolveLauncherPath() {
228
+ const configured = String(process.env.DEEPSCIENTIST_LAUNCHER_PATH || '').trim();
229
+ if (configured && fs.existsSync(configured)) {
230
+ return configured;
231
+ }
232
+ const candidate = path.join(repoRoot, 'bin', 'ds.js');
233
+ return fs.existsSync(candidate) ? candidate : null;
234
+ }
235
+
236
+ function fetchLatestPublishedVersion({ npmBinary, timeoutMs = 3500 }) {
237
+ if (!npmBinary) {
238
+ return {
239
+ ok: false,
240
+ error: '`npm` is not available on PATH.',
241
+ latestVersion: null,
242
+ };
243
+ }
244
+ const result = spawnSync(npmBinary, ['view', UPDATE_PACKAGE_NAME, 'version', '--json'], {
245
+ encoding: 'utf8',
246
+ env: process.env,
247
+ timeout: timeoutMs,
248
+ });
249
+ if (result.error) {
250
+ return {
251
+ ok: false,
252
+ error: result.error.message,
253
+ latestVersion: null,
254
+ };
255
+ }
256
+ if (result.status !== 0) {
257
+ return {
258
+ ok: false,
259
+ error: (result.stderr || result.stdout || '').trim() || `npm exited with status ${result.status}`,
260
+ latestVersion: null,
261
+ };
262
+ }
263
+ try {
264
+ const parsed = JSON.parse(String(result.stdout || 'null'));
265
+ const latestVersion = Array.isArray(parsed) ? normalizeVersion(parsed[parsed.length - 1]) : normalizeVersion(parsed);
266
+ if (!latestVersion) {
267
+ throw new Error('npm returned an empty version string.');
268
+ }
269
+ return {
270
+ ok: true,
271
+ error: null,
272
+ latestVersion,
273
+ };
274
+ } catch (error) {
275
+ return {
276
+ ok: false,
277
+ error: error instanceof Error ? error.message : 'Could not parse npm version output.',
278
+ latestVersion: null,
279
+ };
280
+ }
281
+ }
282
+
283
+ function buildUpdateStatus(home, statePatch = {}) {
284
+ const state = { ...readUpdateState(home), ...statePatch };
285
+ const installMode = detectInstallMode(repoRoot);
286
+ const npmBinary = resolveNpmBinary();
287
+ const launcherPath = resolveLauncherPath();
288
+ const support = updateSupportSummary(installMode, npmBinary, launcherPath);
289
+ const currentVersion = normalizeVersion(state.current_version || packageJson.version);
290
+ const latestVersion = normalizeVersion(state.latest_version || '');
291
+ const updateAvailable = Boolean(latestVersion) && compareVersions(latestVersion, currentVersion) > 0;
292
+ const skippedVersion = normalizeVersion(state.last_skipped_version || '');
293
+ const skippedCurrentTarget = Boolean(updateAvailable && skippedVersion && skippedVersion === latestVersion);
294
+ const promptRecommended =
295
+ Boolean(updateAvailable)
296
+ && !Boolean(state.busy)
297
+ && !skippedCurrentTarget
298
+ && isExpired(state.last_prompted_at || state.last_deferred_at, UPDATE_PROMPT_TTL_MS);
299
+
300
+ return {
301
+ ok: true,
302
+ package_name: UPDATE_PACKAGE_NAME,
303
+ install_mode: installMode,
304
+ can_check: support.canCheck,
305
+ can_self_update: support.canSelfUpdate,
306
+ current_version: currentVersion,
307
+ latest_version: latestVersion || null,
308
+ update_available: updateAvailable,
309
+ prompt_recommended: promptRecommended,
310
+ busy: Boolean(state.busy),
311
+ last_checked_at: state.last_checked_at || null,
312
+ last_check_error: state.last_check_error || null,
313
+ last_prompted_at: state.last_prompted_at || null,
314
+ last_deferred_at: state.last_deferred_at || null,
315
+ last_skipped_version: skippedVersion || null,
316
+ last_update_started_at: state.last_update_started_at || null,
317
+ last_update_finished_at: state.last_update_finished_at || null,
318
+ last_update_result: state.last_update_result || null,
319
+ target_version: normalizeVersion(state.target_version || '') || null,
320
+ manual_update_command: updateManualCommand(installMode),
321
+ reason: support.reason,
322
+ };
323
+ }
324
+
325
+ function checkForUpdates(home, { force = false, timeoutMs = 3500 } = {}) {
326
+ const currentVersion = normalizeVersion(packageJson.version);
327
+ const existing = readUpdateState(home);
328
+ const installMode = detectInstallMode(repoRoot);
329
+ const npmBinary = resolveNpmBinary();
330
+ const launcherPath = resolveLauncherPath();
331
+ const support = updateSupportSummary(installMode, npmBinary, launcherPath);
332
+
333
+ if (!force && existing.current_version === currentVersion && !isExpired(existing.last_checked_at, UPDATE_CHECK_TTL_MS)) {
334
+ return buildUpdateStatus(home);
335
+ }
336
+
337
+ if (!support.canCheck) {
338
+ const patched = mergeUpdateState(home, {
339
+ current_version: currentVersion,
340
+ last_checked_at: new Date().toISOString(),
341
+ last_check_error: support.reason,
342
+ });
343
+ return buildUpdateStatus(home, patched);
344
+ }
345
+
346
+ const probe = fetchLatestPublishedVersion({ npmBinary, timeoutMs });
347
+ const patched = mergeUpdateState(home, {
348
+ current_version: currentVersion,
349
+ latest_version: probe.latestVersion || existing.latest_version || null,
350
+ last_checked_at: new Date().toISOString(),
351
+ last_check_error: probe.ok ? null : probe.error,
352
+ });
353
+ return buildUpdateStatus(home, patched);
354
+ }
355
+
356
+ function markUpdateDeferred(home, version) {
357
+ const patched = mergeUpdateState(home, {
358
+ last_prompted_at: new Date().toISOString(),
359
+ last_deferred_at: new Date().toISOString(),
360
+ latest_version: normalizeVersion(version || readUpdateState(home).latest_version || '') || null,
361
+ });
362
+ return buildUpdateStatus(home, patched);
363
+ }
364
+
365
+ function markUpdateSkipped(home, version) {
366
+ const normalized = normalizeVersion(version);
367
+ const patched = mergeUpdateState(home, {
368
+ last_prompted_at: new Date().toISOString(),
369
+ last_skipped_version: normalized || null,
370
+ });
371
+ return buildUpdateStatus(home, patched);
372
+ }
373
+
374
+ function parseRequiredPythonSpec(pyprojectText) {
375
+ const match = String(pyprojectText || '').match(/^\s*requires-python\s*=\s*["']([^"']+)["']/m);
376
+ return match ? match[1].trim() : '>=3.11';
377
+ }
378
+
379
+ function parseMinimumPythonVersion(spec) {
380
+ const match = String(spec || '').match(/>=\s*(\d+)\.(\d+)(?:\.(\d+))?/);
381
+ if (!match) {
382
+ return { major: 3, minor: 11, patch: 0 };
383
+ }
384
+ return {
385
+ major: Number(match[1]),
386
+ minor: Number(match[2]),
387
+ patch: Number(match[3] || 0),
388
+ };
389
+ }
390
+
61
391
  function resolveHome(args) {
62
392
  const index = args.indexOf('--home');
63
393
  if (index >= 0 && index + 1 < args.length) {
64
394
  return path.resolve(args[index + 1]);
65
395
  }
396
+ if (args.includes('--hero') || args.includes('--here')) {
397
+ return process.cwd();
398
+ }
66
399
  if (process.env.DEEPSCIENTIST_HOME) {
67
400
  return path.resolve(process.env.DEEPSCIENTIST_HOME);
68
401
  }
402
+ if (process.env.DS_HOME) {
403
+ return path.resolve(process.env.DS_HOME);
404
+ }
69
405
  return path.join(os.homedir(), 'DeepScientist');
70
406
  }
71
407
 
@@ -176,11 +512,88 @@ function renderBrandArtwork() {
176
512
  return [];
177
513
  }
178
514
 
179
- function printLaunchCard({ url, bindUrl, mode, autoOpenRequested, browserOpened, daemonOnly }) {
515
+ function truncateMiddle(text, maxLength = 120) {
516
+ const value = String(text || '');
517
+ if (value.length <= maxLength) {
518
+ return value;
519
+ }
520
+ const head = Math.max(24, Math.floor((maxLength - 1) / 2));
521
+ const tail = Math.max(16, maxLength - head - 1);
522
+ return `${value.slice(0, head)}…${value.slice(-tail)}`;
523
+ }
524
+
525
+ function renderKeyValueRows(rows) {
526
+ const labelWidth = Math.max(...rows.map(([label]) => String(label).length), 8);
527
+ for (const [label, value] of rows) {
528
+ console.log(` ${String(label).padEnd(labelWidth)} ${value}`);
529
+ }
530
+ }
531
+
532
+ function pythonMajorMinor(probe) {
533
+ if (!probe || typeof probe.major !== 'number' || typeof probe.minor !== 'number') {
534
+ return '';
535
+ }
536
+ return `${probe.major}.${probe.minor}`;
537
+ }
538
+
539
+ function pythonVersionText(probe) {
540
+ if (!probe) {
541
+ return 'unknown';
542
+ }
543
+ const version = probe.version || pythonMajorMinor(probe) || 'unknown';
544
+ if (probe.executable) {
545
+ return `${version} (${probe.executable})`;
546
+ }
547
+ return version;
548
+ }
549
+
550
+ function renderLaunchHints({ home, url, bindUrl, pythonSelection }) {
551
+ const runtimeRows = [
552
+ ['Version', packageJson.version],
553
+ ['Home', truncateMiddle(home)],
554
+ ['Browser URL', url],
555
+ ['Bind URL', bindUrl],
556
+ ['Python', truncateMiddle(pythonVersionText(pythonSelection))],
557
+ ];
558
+ if (pythonSelection && pythonSelection.sourceLabel) {
559
+ runtimeRows.push(['Python source', pythonSelection.sourceLabel]);
560
+ }
561
+ console.log(colorize('\u001B[1;38;5;39m', 'Runtime'));
562
+ renderKeyValueRows(runtimeRows);
563
+ console.log('');
564
+
565
+ console.log(colorize('\u001B[1;38;5;39m', 'Quick Flags'));
566
+ renderKeyValueRows([
567
+ ['ds --port 21000', 'Change the web port'],
568
+ ['ds --host 0.0.0.0 --port 21000', 'Bind on all interfaces'],
569
+ ['ds --hero', 'Use the current directory as home'],
570
+ ['ds --both', 'Start web + TUI together'],
571
+ ['ds --tui', 'Start the terminal workspace only'],
572
+ ['ds --no-browser', 'Do not auto-open the browser'],
573
+ ['ds --status', 'Show daemon health as JSON'],
574
+ ['ds --restart', 'Restart the managed daemon'],
575
+ ['ds --stop', 'Stop the managed daemon'],
576
+ ['ds migrate /data/DeepScientist', 'Move the full home/install root safely'],
577
+ ['ds --help', 'Show the full launcher help'],
578
+ ]);
579
+ console.log('');
580
+ }
581
+
582
+ function printLaunchCard({
583
+ url,
584
+ bindUrl,
585
+ mode,
586
+ autoOpenRequested,
587
+ browserOpened,
588
+ daemonOnly,
589
+ home,
590
+ pythonSelection,
591
+ }) {
180
592
  const width = Math.max(72, Math.min(process.stdout.columns || 100, 108));
181
593
  const divider = colorize('\u001B[38;5;245m', '─'.repeat(Math.max(36, width - 6)));
182
594
  const title = colorize('\u001B[1;38;5;39m', 'ResearAI');
183
595
  const subtitle = colorize('\u001B[38;5;110m', 'Local-first research operating system');
596
+ const versionLine = colorize('\u001B[38;5;245m', `Version ${packageJson.version}`);
184
597
  const urlLabel = colorize('\u001B[1;38;5;45m', hyperlink(url, url));
185
598
  const workspaceMode =
186
599
  mode === 'both'
@@ -218,6 +631,7 @@ function printLaunchCard({ url, bindUrl, mode, autoOpenRequested, browserOpened,
218
631
  ' |_| ',
219
632
  ];
220
633
  console.log(centerText(title, width));
634
+ console.log(centerText(versionLine, width));
221
635
  for (const line of wordmark) {
222
636
  console.log(centerText(colorize('\u001B[1;38;5;39m', line), width));
223
637
  }
@@ -228,10 +642,11 @@ function printLaunchCard({ url, bindUrl, mode, autoOpenRequested, browserOpened,
228
642
  console.log(centerText(urlLabel, width));
229
643
  console.log(centerText(divider, width));
230
644
  console.log(centerText(browserLine, width));
231
- console.log(centerText(`Daemon bind: ${bindUrl}`, width));
232
645
  console.log(centerText(nextStep, width));
233
646
  console.log(centerText('Run ds --stop to stop the managed daemon.', width));
647
+ console.log(centerText('Need to move this installation later? Use ds migrate /new/path.', width));
234
648
  console.log('');
649
+ renderLaunchHints({ home, url, bindUrl, pythonSelection });
235
650
  }
236
651
 
237
652
  function escapeHtml(value) {
@@ -354,7 +769,7 @@ function writeCodexPreflightReport(home, probe) {
354
769
  };
355
770
  }
356
771
 
357
- function readCodexBootstrapState(home, venvPython) {
772
+ function readCodexBootstrapState(home, runtimePython) {
358
773
  const snippet = [
359
774
  'import json, pathlib, sys',
360
775
  'from deepscientist.config import ConfigManager',
@@ -362,7 +777,7 @@ function readCodexBootstrapState(home, venvPython) {
362
777
  'manager = ConfigManager(home)',
363
778
  'print(json.dumps(manager.codex_bootstrap_state(), ensure_ascii=False))',
364
779
  ].join('\n');
365
- const result = runSync(venvPython, ['-c', snippet, home], { capture: true, allowFailure: true });
780
+ const result = runSync(runtimePython, ['-c', snippet, home], { capture: true, allowFailure: true });
366
781
  if (result.status !== 0) {
367
782
  return { codex_ready: false, codex_last_checked_at: null, codex_last_result: {} };
368
783
  }
@@ -373,7 +788,7 @@ function readCodexBootstrapState(home, venvPython) {
373
788
  }
374
789
  }
375
790
 
376
- function probeCodexBootstrap(home, venvPython) {
791
+ function probeCodexBootstrap(home, runtimePython) {
377
792
  const snippet = [
378
793
  'import json, pathlib, sys',
379
794
  'from deepscientist.config import ConfigManager',
@@ -381,7 +796,7 @@ function probeCodexBootstrap(home, venvPython) {
381
796
  'manager = ConfigManager(home)',
382
797
  'print(json.dumps(manager.probe_codex_bootstrap(persist=True), ensure_ascii=False))',
383
798
  ].join('\n');
384
- const result = runSync(venvPython, ['-c', snippet, home], { capture: true, allowFailure: true });
799
+ const result = runSync(runtimePython, ['-c', snippet, home], { capture: true, allowFailure: true });
385
800
  let payload = null;
386
801
  try {
387
802
  payload = JSON.parse(result.stdout || '{}');
@@ -430,6 +845,7 @@ function parseLauncherArgs(argv) {
430
845
  let questId = null;
431
846
  let status = false;
432
847
  let daemonOnly = false;
848
+ let skipUpdateCheck = false;
433
849
 
434
850
  if (args[0] === 'ui') {
435
851
  args.shift();
@@ -447,6 +863,7 @@ function parseLauncherArgs(argv) {
447
863
  else if (arg === '--no-browser') openBrowser = false;
448
864
  else if (arg === '--open-browser') openBrowser = true;
449
865
  else if (arg === '--daemon-only') daemonOnly = true;
866
+ else if (arg === '--skip-update-check') skipUpdateCheck = true;
450
867
  else if (arg === '--host' && args[index + 1]) host = args[++index];
451
868
  else if (arg === '--port' && args[index + 1]) port = Number(args[++index]);
452
869
  else if (arg === '--home' && args[index + 1]) home = path.resolve(args[++index]);
@@ -468,6 +885,133 @@ function parseLauncherArgs(argv) {
468
885
  openBrowser,
469
886
  questId,
470
887
  daemonOnly,
888
+ skipUpdateCheck,
889
+ };
890
+ }
891
+
892
+ function printUpdateHelp() {
893
+ console.log(`DeepScientist update
894
+
895
+ Usage:
896
+ ds update
897
+ ds update --check
898
+ ds update --yes
899
+ ds update --remind-later
900
+ ds update --skip-version
901
+
902
+ Flags:
903
+ --check Return the current update status without installing
904
+ --yes Install the latest published npm package immediately
905
+ --json Print structured JSON output
906
+ --force-check Ignore the cached version probe
907
+ --remind-later Defer prompts for the current published version
908
+ --skip-version Skip reminders for the current published version
909
+ `);
910
+ }
911
+
912
+ function printMigrateHelp() {
913
+ console.log(`DeepScientist migrate
914
+
915
+ Usage:
916
+ ds migrate /absolute/target/path
917
+ ds migrate /absolute/target/path --yes
918
+ ds migrate /absolute/target/path --restart
919
+ ds migrate /absolute/target/path --home /current/source/path
920
+
921
+ Flags:
922
+ --yes Skip the interactive double-confirmation prompt
923
+ --restart Start the managed daemon again from the migrated home
924
+ --home <path> Override the current DeepScientist source home/root
925
+ `);
926
+ }
927
+
928
+ function parseUpdateArgs(argv) {
929
+ const args = [...argv];
930
+ if (args[0] === 'update') {
931
+ args.shift();
932
+ }
933
+ let json = false;
934
+ let check = false;
935
+ let yes = false;
936
+ let forceCheck = false;
937
+ let remindLater = false;
938
+ let skipVersion = false;
939
+ let background = false;
940
+ let worker = false;
941
+ let home = null;
942
+ let host = null;
943
+ let port = null;
944
+ let restartDaemon = null;
945
+ let skipUpdateCheck = false;
946
+
947
+ for (let index = 0; index < args.length; index += 1) {
948
+ const arg = args[index];
949
+ if (arg === '--json') json = true;
950
+ else if (arg === '--check') check = true;
951
+ else if (arg === '--yes') yes = true;
952
+ else if (arg === '--force-check') forceCheck = true;
953
+ else if (arg === '--remind-later') remindLater = true;
954
+ else if (arg === '--skip-version') skipVersion = true;
955
+ else if (arg === '--background') background = true;
956
+ else if (arg === '--worker') worker = true;
957
+ else if (arg === '--restart-daemon') restartDaemon = true;
958
+ else if (arg === '--skip-update-check') skipUpdateCheck = true;
959
+ else if (arg === '--home' && args[index + 1]) home = path.resolve(args[++index]);
960
+ else if (arg === '--host' && args[index + 1]) host = args[++index];
961
+ else if (arg === '--port' && args[index + 1]) port = Number(args[++index]);
962
+ else if (arg === '--help' || arg === '-h') return { help: true };
963
+ else if (!arg.startsWith('--')) return null;
964
+ }
965
+
966
+ return {
967
+ help: false,
968
+ json,
969
+ check,
970
+ yes,
971
+ forceCheck,
972
+ remindLater,
973
+ skipVersion,
974
+ background,
975
+ worker,
976
+ home,
977
+ host,
978
+ port,
979
+ restartDaemon,
980
+ skipUpdateCheck,
981
+ };
982
+ }
983
+
984
+ function parseMigrateArgs(argv) {
985
+ const args = [...argv];
986
+ if (args[0] === 'migrate') {
987
+ args.shift();
988
+ }
989
+ let home = null;
990
+ let target = null;
991
+ let yes = false;
992
+ let restart = false;
993
+
994
+ for (let index = 0; index < args.length; index += 1) {
995
+ const arg = args[index];
996
+ if (arg === '--yes') yes = true;
997
+ else if (arg === '--restart') restart = true;
998
+ else if (arg === '--home' && args[index + 1]) home = path.resolve(expandUserPath(args[++index]));
999
+ else if (arg === '--help' || arg === '-h') return { help: true };
1000
+ else if (arg.startsWith('--')) return null;
1001
+ else if (!target) target = path.resolve(expandUserPath(arg));
1002
+ else return null;
1003
+ }
1004
+
1005
+ if (!target) {
1006
+ return null;
1007
+ }
1008
+
1009
+ return {
1010
+ help: false,
1011
+ home,
1012
+ target,
1013
+ yes,
1014
+ restart,
471
1015
  };
472
1016
  }
473
1017
 
@@ -486,76 +1030,498 @@ function findFirstPositionalArg(args) {
486
1030
  return null;
487
1031
  }
488
1032
 
489
- function resolveSystemPython() {
490
- for (const binary of pythonCandidates) {
491
- const result = spawnSync(binary, ['--version'], { stdio: 'ignore' });
492
- if (result.status === 0) {
493
- return binary;
494
- }
1033
+ function realpathOrSelf(targetPath) {
1034
+ try {
1035
+ return fs.realpathSync(targetPath);
1036
+ } catch {
1037
+ return targetPath;
495
1038
  }
496
- console.error('DeepScientist could not find a working Python 3 interpreter.');
497
- process.exit(1);
498
1039
  }
499
1040
 
500
- function venvPythonPath(home) {
501
- return process.platform === 'win32'
502
- ? path.join(home, 'runtime', 'venv', 'Scripts', 'python.exe')
503
- : path.join(home, 'runtime', 'venv', 'bin', 'python');
1041
+ function isPathEqual(left, right) {
1042
+ return realpathOrSelf(path.resolve(left)) === realpathOrSelf(path.resolve(right));
504
1043
  }
505
1044
 
506
- function venvRootPath(home) {
507
- return path.join(home, 'runtime', 'venv');
1045
+ function isPathInside(candidatePath, parentPath) {
1046
+ const candidate = realpathOrSelf(path.resolve(candidatePath));
1047
+ const parent = realpathOrSelf(path.resolve(parentPath));
1048
+ if (candidate === parent) {
1049
+ return false;
1050
+ }
1051
+ const relative = path.relative(parent, candidate);
1052
+ return Boolean(relative && !relative.startsWith('..') && !path.isAbsolute(relative));
508
1053
  }
509
1054
 
510
- function sha256File(filePath) {
511
- return crypto.createHash('sha256').update(fs.readFileSync(filePath)).digest('hex');
1055
+ function buildInstalledWrapperScript() {
1056
+ return [
1057
+ '#!/usr/bin/env bash',
1058
+ 'set -euo pipefail',
1059
+ 'SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"',
1060
+ 'HOME_DIR="$(cd "$SCRIPT_DIR/../.." && pwd)"',
1061
+ 'if [ -z "${DEEPSCIENTIST_HOME:-}" ]; then',
1062
+ ' export DEEPSCIENTIST_HOME="$HOME_DIR"',
1063
+ 'fi',
1064
+ 'NODE_BIN="${DEEPSCIENTIST_NODE:-node}"',
1065
+ 'exec "$NODE_BIN" "$SCRIPT_DIR/ds.js" "$@"',
1066
+ '',
1067
+ ].join('\n');
512
1068
  }
513
1069
 
514
- function hashSkillTree() {
515
- const skillsRoot = path.join(repoRoot, 'src', 'skills');
516
- const hasher = crypto.createHash('sha256');
517
- if (!fs.existsSync(skillsRoot)) {
518
- hasher.update('missing');
519
- return hasher.digest('hex');
1070
+ function buildGlobalWrapperScript({ installDir, home, commandName }) {
1071
+ return [
1072
+ '#!/usr/bin/env bash',
1073
+ 'set -euo pipefail',
1074
+ 'if [ -z "${DEEPSCIENTIST_HOME:-}" ]; then',
1075
+ ` export DEEPSCIENTIST_HOME="${home}"`,
1076
+ 'fi',
1077
+ `exec "${path.join(installDir, 'bin', commandName)}" "$@"`,
1078
+ '',
1079
+ ].join('\n');
1080
+ }
1081
+
1082
+ function writeExecutableScript(targetPath, content) {
1083
+ ensureDir(path.dirname(targetPath));
1084
+ fs.writeFileSync(targetPath, content, { encoding: 'utf8', mode: 0o755 });
1085
+ fs.chmodSync(targetPath, 0o755);
1086
+ }
1087
+
1088
+ function repairMigratedInstallWrappers(targetHome) {
1089
+ const installBinDir = path.join(targetHome, 'cli', 'bin');
1090
+ if (!fs.existsSync(installBinDir)) {
1091
+ return;
520
1092
  }
521
- const stack = [skillsRoot];
522
- const files = [];
523
- while (stack.length > 0) {
524
- const current = stack.pop();
525
- for (const entry of fs.readdirSync(current, { withFileTypes: true })) {
526
- const fullPath = path.join(current, entry.name);
527
- if (entry.isDirectory()) {
528
- stack.push(fullPath);
529
- continue;
530
- }
531
- if (entry.isFile()) {
532
- files.push(fullPath);
533
- }
1093
+ const content = buildInstalledWrapperScript();
1094
+ for (const commandName of launcherWrapperCommands) {
1095
+ const wrapperPath = path.join(installBinDir, commandName);
1096
+ if (!fs.existsSync(wrapperPath)) {
1097
+ continue;
534
1098
  }
1099
+ writeExecutableScript(wrapperPath, content);
535
1100
  }
536
- files.sort();
537
- for (const filePath of files) {
538
- hasher.update(path.relative(skillsRoot, filePath));
539
- hasher.update(fs.readFileSync(filePath));
1101
+ }
1102
+
1103
+ function candidateWrapperPathsForCommand(commandName) {
1104
+ const directories = String(process.env.PATH || '')
1105
+ .split(path.delimiter)
1106
+ .filter(Boolean);
1107
+ const candidates = [];
1108
+ for (const directory of directories) {
1109
+ candidates.push(path.join(directory, commandName));
1110
+ if (process.platform === 'win32') {
1111
+ candidates.push(path.join(directory, `${commandName}.cmd`));
1112
+ candidates.push(path.join(directory, `${commandName}.ps1`));
1113
+ }
540
1114
  }
541
- return hasher.digest('hex');
1115
+ return candidates;
542
1116
  }
543
1117
 
544
- function discoverSkillIds() {
545
- const skillsRoot = path.join(repoRoot, 'src', 'skills');
546
- if (!fs.existsSync(skillsRoot)) {
1118
+ function rewriteLauncherWrappersIfPointingAtSource({ sourceHome, targetHome }) {
1119
+ if (process.platform === 'win32') {
547
1120
  return [];
548
1121
  }
549
- return fs
550
- .readdirSync(skillsRoot, { withFileTypes: true })
551
- .filter(
552
- (entry) =>
553
- entry.isDirectory() &&
554
- !entry.name.startsWith('.') &&
555
- fs.existsSync(path.join(skillsRoot, entry.name, 'SKILL.md'))
556
- )
557
- .map((entry) => entry.name)
558
- .sort();
1122
+ const rewritten = [];
1123
+ const sourceInstallDir = path.join(sourceHome, 'cli');
1124
+ const targetInstallDir = path.join(targetHome, 'cli');
1125
+ for (const commandName of launcherWrapperCommands) {
1126
+ for (const candidate of candidateWrapperPathsForCommand(commandName)) {
1127
+ if (!fs.existsSync(candidate) || !fs.statSync(candidate).isFile()) {
1128
+ continue;
1129
+ }
1130
+ let text = '';
1131
+ try {
1132
+ text = fs.readFileSync(candidate, 'utf8');
1133
+ } catch {
1134
+ continue;
1135
+ }
1136
+ if (!text.includes(sourceInstallDir) && !text.includes(sourceHome)) {
1137
+ continue;
1138
+ }
1139
+ writeExecutableScript(
1140
+ candidate,
1141
+ buildGlobalWrapperScript({
1142
+ installDir: targetInstallDir,
1143
+ home: targetHome,
1144
+ commandName,
1145
+ })
1146
+ );
1147
+ rewritten.push(candidate);
1148
+ }
1149
+ }
1150
+ return rewritten;
1151
+ }
1152
+
1153
+ function scheduleDeferredSourceCleanup({ sourceHome, targetHome }) {
1154
+ const logPath = path.join(targetHome, 'logs', 'migrate-cleanup.log');
1155
+ ensureDir(path.dirname(logPath));
1156
+ const helperScript = [
1157
+ "const fs = require('node:fs');",
1158
+ "const { setTimeout: sleep } = require('node:timers/promises');",
1159
+ 'const parentPid = Number(process.argv[1]);',
1160
+ 'const sourceHome = process.argv[2];',
1161
+ 'const logPath = process.argv[3];',
1162
+ '(async () => {',
1163
+ ' for (let attempt = 0; attempt < 300; attempt += 1) {',
1164
+ ' try {',
1165
+ ' process.kill(parentPid, 0);',
1166
+ ' await sleep(100);',
1167
+ ' continue;',
1168
+ ' } catch {',
1169
+ ' break;',
1170
+ ' }',
1171
+ ' }',
1172
+ ' try {',
1173
+ ' fs.rmSync(sourceHome, { recursive: true, force: true });',
1174
+ ' } catch (error) {',
1175
+ " fs.appendFileSync(logPath, `[${new Date().toISOString()}] ${error instanceof Error ? error.message : String(error)}\\n`, 'utf8');",
1176
+ ' process.exit(1);',
1177
+ ' }',
1178
+ '})();',
1179
+ ].join('\n');
1180
+ const child = spawn(process.execPath, ['-e', helperScript, String(process.pid), sourceHome, logPath], {
1181
+ detached: true,
1182
+ stdio: 'ignore',
1183
+ env: process.env,
1184
+ });
1185
+ child.unref();
1186
+ }
1187
+
1188
+ async function promptMigrationConfirmation({ sourceHome, targetHome }) {
1189
+ if (!process.stdin.isTTY || !process.stdout.isTTY) {
1190
+ throw new Error('DeepScientist migration needs a TTY for confirmation. Re-run with `--yes` to continue non-interactively.');
1191
+ }
1192
+ console.log('');
1193
+ console.log('DeepScientist home migration');
1194
+ console.log('');
1195
+ console.log(`From: ${sourceHome}`);
1196
+ console.log(`To: ${targetHome}`);
1197
+ console.log('');
1198
+ console.log('This will stop the managed daemon, copy the full DeepScientist root, verify the copy, update launcher wrappers, and delete the old path after success.');
1199
+ const ask = (question) => new Promise((resolve) => {
1200
+ const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
1201
+ rl.question(question, (answer) => {
1202
+ rl.close();
1203
+ resolve(String(answer || '').trim());
1204
+ });
1205
+ });
1206
+ const first = await ask('Type YES to continue: ');
1207
+ if (first !== 'YES') {
1208
+ return false;
1209
+ }
1210
+ const second = await ask('Type MIGRATE to confirm old-path deletion after a successful copy: ');
1211
+ return second === 'MIGRATE';
1212
+ }
1213
+
1214
+ function printMigrationSummary({ sourceHome, targetHome, restart }) {
1215
+ console.log('');
1216
+ console.log('DeepScientist migrate');
1217
+ console.log('');
1218
+ console.log(`Source: ${sourceHome}`);
1219
+ console.log(`Target: ${targetHome}`);
1220
+ console.log(`Restart: ${restart ? 'yes' : 'no'}`);
1221
+ }
1222
+
1223
+ function pythonMeetsMinimum(probe) {
1224
+ if (!probe || typeof probe.major !== 'number' || typeof probe.minor !== 'number') {
1225
+ return false;
1226
+ }
1227
+ if (probe.major !== minimumPythonVersion.major) {
1228
+ return probe.major > minimumPythonVersion.major;
1229
+ }
1230
+ if (probe.minor !== minimumPythonVersion.minor) {
1231
+ return probe.minor > minimumPythonVersion.minor;
1232
+ }
1233
+ return probe.patch >= minimumPythonVersion.patch;
1234
+ }
1235
+
1236
+ function pythonSelectionLabel(source) {
1237
+ if (source === 'conda') {
1238
+ const envName = String(process.env.CONDA_DEFAULT_ENV || '').trim();
1239
+ return envName ? `conda:${envName}` : 'conda';
1240
+ }
1241
+ if (source === 'uv-managed') {
1242
+ return 'uv-managed';
1243
+ }
1244
+ return 'path';
1245
+ }
1246
+
1247
+ function buildCondaPythonCandidates() {
1248
+ const prefix = String(process.env.CONDA_PREFIX || '').trim();
1249
+ if (!prefix) {
1250
+ return [];
1251
+ }
1252
+ if (process.platform === 'win32') {
1253
+ return [path.join(prefix, 'python.exe'), path.join(prefix, 'Scripts', 'python.exe')];
1254
+ }
1255
+ return [path.join(prefix, 'bin', 'python'), path.join(prefix, 'bin', 'python3')];
1256
+ }
1257
+
1258
+ function probePython(binary) {
1259
+ const snippet = [
1260
+ 'import json, sys',
1261
+ 'print(json.dumps({',
1262
+ ' "executable": sys.executable,',
1263
+ ' "version": ".".join(str(part) for part in sys.version_info[:3]),',
1264
+ ' "major": sys.version_info[0],',
1265
+ ' "minor": sys.version_info[1],',
1266
+ ' "patch": sys.version_info[2],',
1267
+ '}, ensure_ascii=False))',
1268
+ ].join('\n');
1269
+ const result = spawnSync(binary, ['-c', snippet], {
1270
+ encoding: 'utf8',
1271
+ env: process.env,
1272
+ });
1273
+ if (result.error) {
1274
+ return {
1275
+ ok: false,
1276
+ binary,
1277
+ error: result.error.message,
1278
+ };
1279
+ }
1280
+ if (result.status !== 0) {
1281
+ return {
1282
+ ok: false,
1283
+ binary,
1284
+ error: (result.stderr || result.stdout || '').trim() || `exit ${result.status}`,
1285
+ };
1286
+ }
1287
+ try {
1288
+ const payload = JSON.parse(result.stdout || '{}');
1289
+ const executable = String(payload.executable || '').trim();
1290
+ return {
1291
+ ok: true,
1292
+ binary,
1293
+ executable,
1294
+ realExecutable: executable ? realpathOrSelf(executable) : '',
1295
+ version: String(payload.version || '').trim(),
1296
+ major: Number(payload.major),
1297
+ minor: Number(payload.minor),
1298
+ patch: Number(payload.patch),
1299
+ };
1300
+ } catch (error) {
1301
+ return {
1302
+ ok: false,
1303
+ binary,
1304
+ error: error instanceof Error ? error.message : 'Could not parse Python version probe.',
1305
+ };
1306
+ }
1307
+ }
1308
+
1309
+ function minimumPythonRequest() {
1310
+ return `${minimumPythonVersion.major}.${minimumPythonVersion.minor}`;
1311
+ }
1312
+
1313
+ function decoratePythonProbe(probe, source) {
1314
+ if (!probe || !probe.ok) {
1315
+ return null;
1316
+ }
1317
+ return {
1318
+ ...probe,
1319
+ source,
1320
+ sourceLabel: pythonSelectionLabel(source),
1321
+ };
1322
+ }
1323
+
1324
+ function collectPythonProbes(binaries, source, seenExecutables) {
1325
+ const probes = [];
1326
+ for (const candidate of binaries) {
1327
+ const resolved = decoratePythonProbe(probePython(candidate), source);
1328
+ if (!resolved) {
1329
+ continue;
1330
+ }
1331
+ const executableKey = resolved.realExecutable || resolved.executable || resolved.binary;
1332
+ if (seenExecutables.has(executableKey)) {
1333
+ continue;
1334
+ }
1335
+ seenExecutables.add(executableKey);
1336
+ probes.push(resolved);
1337
+ }
1338
+ return probes;
1339
+ }
1340
+
1341
+ function createPythonRuntimePlan({ condaProbes = [], pathProbes = [], minimumVersionRequest = minimumPythonRequest() }) {
1342
+ const validConda = condaProbes.find((probe) => pythonMeetsMinimum(probe)) || null;
1343
+ if (validConda) {
1344
+ return {
1345
+ runtimeKind: 'system',
1346
+ selectedProbe: validConda,
1347
+ source: 'conda',
1348
+ sourceLabel: validConda.sourceLabel,
1349
+ };
1350
+ }
1351
+ const firstConda = condaProbes[0] || null;
1352
+ if (firstConda) {
1353
+ return {
1354
+ runtimeKind: 'managed',
1355
+ selectedProbe: null,
1356
+ rejectedProbe: firstConda,
1357
+ source: 'conda',
1358
+ sourceLabel: pythonSelectionLabel('uv-managed'),
1359
+ minimumVersionRequest,
1360
+ };
1361
+ }
1362
+
1363
+ const validPath = pathProbes.find((probe) => pythonMeetsMinimum(probe)) || null;
1364
+ if (validPath) {
1365
+ return {
1366
+ runtimeKind: 'system',
1367
+ selectedProbe: validPath,
1368
+ source: 'path',
1369
+ sourceLabel: validPath.sourceLabel,
1370
+ };
1371
+ }
1372
+ const firstPath = pathProbes[0] || null;
1373
+ if (firstPath) {
1374
+ return {
1375
+ runtimeKind: 'managed',
1376
+ selectedProbe: null,
1377
+ rejectedProbe: firstPath,
1378
+ source: 'path',
1379
+ sourceLabel: pythonSelectionLabel('uv-managed'),
1380
+ minimumVersionRequest,
1381
+ };
1382
+ }
1383
+
1384
+ return {
1385
+ runtimeKind: 'managed',
1386
+ selectedProbe: null,
1387
+ rejectedProbe: null,
1388
+ source: 'uv-managed',
1389
+ sourceLabel: pythonSelectionLabel('uv-managed'),
1390
+ minimumVersionRequest,
1391
+ };
1392
+ }
1393
+
1394
+ function printManagedPythonFallbackNotice({ rejectedProbe, source, minimumVersionRequest, installDir }) {
1395
+ if (!rejectedProbe) {
1396
+ return;
1397
+ }
1398
+ const envName = String(process.env.CONDA_DEFAULT_ENV || '').trim();
1399
+ const sourceLabel =
1400
+ source === 'conda'
1401
+ ? (envName ? `active conda environment \`${envName}\`` : 'active conda environment')
1402
+ : 'detected system Python';
1403
+ console.warn('');
1404
+ console.warn(
1405
+ `DeepScientist found ${sourceLabel} at ${pythonVersionText(rejectedProbe)}, which does not satisfy Python ${requiredPythonSpec}.`
1406
+ );
1407
+ console.warn(
1408
+ `DeepScientist will provision a uv-managed Python ${minimumVersionRequest}+ runtime under ${installDir}.`
1409
+ );
1410
+ console.warn('');
1411
+ }
1412
+
1413
+ function resolvePythonRuntimePlan() {
1414
+ const seenExecutables = new Set();
1415
+ const condaProbes = collectPythonProbes(buildCondaPythonCandidates(), 'conda', seenExecutables);
1416
+ const pathProbes = collectPythonProbes(pythonCandidates, 'path', seenExecutables);
1417
+ return createPythonRuntimePlan({ condaProbes, pathProbes, minimumVersionRequest: minimumPythonRequest() });
1418
+ }
1419
+
1420
+ function runtimePythonEnvPath(home) {
1421
+ return path.join(home, 'runtime', 'python-env');
1422
+ }
1423
+
1424
+ function runtimePythonPath(home) {
1425
+ return process.platform === 'win32'
1426
+ ? path.join(runtimePythonEnvPath(home), 'Scripts', 'python.exe')
1427
+ : path.join(runtimePythonEnvPath(home), 'bin', 'python');
1428
+ }
1429
+
1430
+ function runtimeUvCachePath(home) {
1431
+ return path.join(home, 'runtime', 'uv-cache');
1432
+ }
1433
+
1434
+ function runtimeUvPythonInstallPath(home) {
1435
+ return path.join(home, 'runtime', 'python');
1436
+ }
1437
+
1438
+ function runtimeToolsPath(home) {
1439
+ return path.join(home, 'runtime', 'tools');
1440
+ }
1441
+
1442
+ function runtimeUvRootPath(home) {
1443
+ return path.join(runtimeToolsPath(home), 'uv');
1444
+ }
1445
+
1446
+ function runtimeUvBinDir(home) {
1447
+ return path.join(runtimeUvRootPath(home), 'bin');
1448
+ }
1449
+
1450
+ function runtimeUvBinaryPath(home) {
1451
+ return path.join(runtimeUvBinDir(home), process.platform === 'win32' ? 'uv.exe' : 'uv');
1452
+ }
1453
+
1454
+ function legacyVenvRootPath(home) {
1455
+ return path.join(home, 'runtime', 'venv');
1456
+ }
1457
+
1458
+ function useEditableProjectInstall() {
1459
+ return fs.existsSync(path.join(repoRoot, '.git'));
1460
+ }
1461
+
1462
+ function uvLockPath() {
1463
+ return path.join(repoRoot, 'uv.lock');
1464
+ }
1465
+
1466
+ function sha256File(filePath) {
1467
+ return crypto.createHash('sha256').update(fs.readFileSync(filePath)).digest('hex');
1468
+ }
1469
+
1470
+ function hashDirectoryTree(rootPath, predicate = null) {
1471
+ const hasher = crypto.createHash('sha256');
1472
+ if (!fs.existsSync(rootPath)) {
1473
+ hasher.update('missing');
1474
+ return hasher.digest('hex');
1475
+ }
1476
+ const stack = [rootPath];
1477
+ const files = [];
1478
+ while (stack.length > 0) {
1479
+ const current = stack.pop();
1480
+ for (const entry of fs.readdirSync(current, { withFileTypes: true })) {
1481
+ const fullPath = path.join(current, entry.name);
1482
+ if (entry.isDirectory()) {
1483
+ stack.push(fullPath);
1484
+ continue;
1485
+ }
1486
+ if (entry.isFile()) {
1487
+ if (typeof predicate === 'function' && !predicate(fullPath)) {
1488
+ continue;
1489
+ }
1490
+ files.push(fullPath);
1491
+ }
1492
+ }
1493
+ }
1494
+ files.sort();
1495
+ for (const filePath of files) {
1496
+ hasher.update(path.relative(rootPath, filePath));
1497
+ hasher.update(fs.readFileSync(filePath));
1498
+ }
1499
+ return hasher.digest('hex');
1500
+ }
1501
+
1502
+ function hashSkillTree() {
1503
+ return hashDirectoryTree(path.join(repoRoot, 'src', 'skills'));
1504
+ }
1505
+
1506
+ function hashPythonSourceTree() {
1507
+ return hashDirectoryTree(path.join(repoRoot, 'src', 'deepscientist'), (filePath) => filePath.endsWith('.py'));
1508
+ }
1509
+
1510
+ function discoverSkillIds() {
1511
+ const skillsRoot = path.join(repoRoot, 'src', 'skills');
1512
+ if (!fs.existsSync(skillsRoot)) {
1513
+ return [];
1514
+ }
1515
+ return fs
1516
+ .readdirSync(skillsRoot, { withFileTypes: true })
1517
+ .filter(
1518
+ (entry) =>
1519
+ entry.isDirectory() &&
1520
+ !entry.name.startsWith('.') &&
1521
+ fs.existsSync(path.join(skillsRoot, entry.name, 'SKILL.md'))
1522
+ )
1523
+ .map((entry) => entry.name)
1524
+ .sort();
559
1525
  }
560
1526
 
561
1527
  function globalSkillsInstalled() {
@@ -571,15 +1537,11 @@ function globalSkillsInstalled() {
571
1537
 
572
1538
  function runSync(binary, args, options = {}) {
573
1539
  const result = spawnSync(binary, args, {
574
- cwd: repoRoot,
1540
+ cwd: options.cwd || repoRoot,
575
1541
  stdio: options.capture ? 'pipe' : 'inherit',
576
- env: {
577
- ...process.env,
578
- PYTHONPATH: process.env.PYTHONPATH
579
- ? `${srcPath}${path.delimiter}${process.env.PYTHONPATH}`
580
- : srcPath,
581
- },
1542
+ env: options.env || process.env,
582
1543
  encoding: 'utf8',
1544
+ input: options.input,
583
1545
  });
584
1546
  if (result.error) {
585
1547
  throw result.error;
@@ -597,72 +1559,468 @@ function step(index, total, message) {
597
1559
  console.log(`[${index}/${total}] ${message}`);
598
1560
  }
599
1561
 
600
- function installPythonBundle(venvPython) {
601
- runSync(venvPython, ['-m', 'pip', 'install', '--upgrade', 'pip', 'setuptools', 'wheel']);
602
- runSync(venvPython, ['-m', 'pip', 'install', '--upgrade', repoRoot]);
603
- }
604
-
605
- function verifyPythonRuntime(venvPython) {
1562
+ function verifyPythonRuntime(runtimePython) {
606
1563
  const result = runSync(
607
- venvPython,
1564
+ runtimePython,
608
1565
  ['-c', 'import deepscientist.cli; import cryptography; import _cffi_backend; print("ok")'],
609
1566
  { capture: true, allowFailure: true }
610
1567
  );
611
1568
  return result.status === 0;
612
1569
  }
613
1570
 
614
- function recreatePythonRuntime(home, systemPython) {
615
- fs.rmSync(venvRootPath(home), { recursive: true, force: true });
616
- step(1, 4, 'Creating local Python runtime');
617
- runSync(systemPython, ['-m', 'venv', venvRootPath(home)]);
1571
+ function readJsonFile(filePath) {
1572
+ if (!fs.existsSync(filePath)) {
1573
+ return null;
1574
+ }
1575
+ try {
1576
+ return JSON.parse(fs.readFileSync(filePath, 'utf8'));
1577
+ } catch {
1578
+ return null;
1579
+ }
1580
+ }
1581
+
1582
+ function executableExtensions() {
1583
+ if (process.platform !== 'win32') {
1584
+ return [''];
1585
+ }
1586
+ return (process.env.PATHEXT || '.EXE;.CMD;.BAT;.COM')
1587
+ .split(';')
1588
+ .filter(Boolean);
1589
+ }
1590
+
1591
+ function candidateExecutablePaths(basePath) {
1592
+ if (process.platform !== 'win32') {
1593
+ return [basePath];
1594
+ }
1595
+ const extension = path.extname(basePath);
1596
+ if (extension) {
1597
+ return [basePath];
1598
+ }
1599
+ return executableExtensions().map((suffix) => `${basePath}${suffix}`);
1600
+ }
1601
+
1602
+ function isExecutableFile(candidate) {
1603
+ try {
1604
+ if (!fs.existsSync(candidate)) {
1605
+ return false;
1606
+ }
1607
+ const stat = fs.statSync(candidate);
1608
+ if (!stat.isFile()) {
1609
+ return false;
1610
+ }
1611
+ if (process.platform !== 'win32') {
1612
+ fs.accessSync(candidate, fs.constants.X_OK);
1613
+ }
1614
+ return true;
1615
+ } catch {
1616
+ return false;
1617
+ }
1618
+ }
1619
+
1620
+ function resolveBinaryReference(reference) {
1621
+ const normalized = String(reference || '').trim();
1622
+ if (!normalized) {
1623
+ return null;
1624
+ }
1625
+ const expanded = expandUserPath(normalized);
1626
+ if (
1627
+ path.isAbsolute(expanded)
1628
+ || normalized.startsWith('.')
1629
+ || normalized.includes(path.sep)
1630
+ || (path.sep === '\\' ? normalized.includes('/') : normalized.includes('\\'))
1631
+ ) {
1632
+ const absolute = path.isAbsolute(expanded) ? expanded : path.resolve(expanded);
1633
+ for (const candidate of candidateExecutablePaths(absolute)) {
1634
+ if (isExecutableFile(candidate)) {
1635
+ return candidate;
1636
+ }
1637
+ }
1638
+ return null;
1639
+ }
1640
+ return resolveExecutableOnPath(expanded);
1641
+ }
1642
+
1643
+ function resolveUvBinary(home) {
1644
+ const configured = String(process.env.DEEPSCIENTIST_UV || process.env.UV_BIN || '').trim();
1645
+ if (configured) {
1646
+ return {
1647
+ path: resolveBinaryReference(configured),
1648
+ source: 'env',
1649
+ configured,
1650
+ };
1651
+ }
1652
+ const local = resolveBinaryReference(runtimeUvBinaryPath(home));
1653
+ if (local) {
1654
+ return {
1655
+ path: local,
1656
+ source: 'local',
1657
+ configured: null,
1658
+ };
1659
+ }
1660
+ const discovered = resolveExecutableOnPath('uv');
1661
+ return {
1662
+ path: discovered,
1663
+ source: discovered ? 'path' : null,
1664
+ configured: null,
1665
+ };
1666
+ }
1667
+
1668
+ function printUvInstallGuidance(home, errorMessage = null) {
1669
+ console.error('');
1670
+ if (errorMessage) {
1671
+ console.error(`DeepScientist could not prepare a local uv runtime manager: ${errorMessage}`);
1672
+ } else {
1673
+ console.error('DeepScientist could not find a usable uv runtime manager.');
1674
+ }
1675
+ console.error(`DeepScientist normally installs uv automatically under ${runtimeUvBinDir(home)}.`);
1676
+ console.error('If the automatic bootstrap fails, install uv manually and run `ds` again.');
1677
+ console.error('');
1678
+ if (process.platform === 'win32') {
1679
+ console.error('Windows PowerShell:');
1680
+ console.error(' powershell -ExecutionPolicy ByPass -c "irm https://astral.sh/uv/install.ps1 | iex"');
1681
+ } else {
1682
+ console.error('macOS / Linux:');
1683
+ console.error(' curl -LsSf https://astral.sh/uv/install.sh | sh');
1684
+ }
1685
+ console.error('Alternative:');
1686
+ console.error(' pipx install uv');
1687
+ console.error('');
1688
+ }
1689
+
1690
+ function downloadFileWithNode(url, destinationPath) {
1691
+ const downloader = [
1692
+ 'const fs = require("node:fs");',
1693
+ 'const url = process.argv[1];',
1694
+ 'const destination = process.argv[2];',
1695
+ 'const timeoutMs = Number(process.argv[3] || "45000");',
1696
+ '(async () => {',
1697
+ ' const controller = new AbortController();',
1698
+ ' const timer = setTimeout(() => controller.abort(), timeoutMs);',
1699
+ ' try {',
1700
+ ' const response = await fetch(url, { signal: controller.signal });',
1701
+ ' if (!response.ok) {',
1702
+ ' throw new Error(`HTTP ${response.status} ${response.statusText}`);',
1703
+ ' }',
1704
+ ' const body = await response.text();',
1705
+ ' fs.writeFileSync(destination, body, "utf8");',
1706
+ ' } finally {',
1707
+ ' clearTimeout(timer);',
1708
+ ' }',
1709
+ '})().catch((error) => {',
1710
+ ' console.error(error instanceof Error ? error.message : String(error));',
1711
+ ' process.exit(1);',
1712
+ '});',
1713
+ ].join('\n');
1714
+ const result = spawnSync(process.execPath, ['-e', downloader, url, destinationPath, '45000'], {
1715
+ cwd: repoRoot,
1716
+ stdio: 'inherit',
1717
+ env: process.env,
1718
+ });
1719
+ if (result.error) {
1720
+ throw result.error;
1721
+ }
1722
+ if (result.status !== 0) {
1723
+ throw new Error(`Download failed with status ${result.status ?? 1}.`);
1724
+ }
1725
+ }
1726
+
1727
+ function installLocalUv(home) {
1728
+ const uvRoot = runtimeUvRootPath(home);
1729
+ const binDir = runtimeUvBinDir(home);
1730
+ const tempDir = path.join(uvRoot, 'tmp');
1731
+ const installerName = process.platform === 'win32' ? 'install-uv.ps1' : 'install-uv.sh';
1732
+ const installerUrl =
1733
+ process.platform === 'win32'
1734
+ ? 'https://astral.sh/uv/install.ps1'
1735
+ : 'https://astral.sh/uv/install.sh';
1736
+ const installerPath = path.join(tempDir, installerName);
1737
+
1738
+ ensureDir(binDir);
1739
+ ensureDir(tempDir);
1740
+
1741
+ console.log(`DeepScientist is installing a local uv runtime manager under ${binDir}.`);
1742
+ downloadFileWithNode(installerUrl, installerPath);
1743
+
1744
+ const installEnv = {
1745
+ ...process.env,
1746
+ UV_UNMANAGED_INSTALL: binDir,
1747
+ };
1748
+
1749
+ let shellBinary;
1750
+ let shellArgs;
1751
+ if (process.platform === 'win32') {
1752
+ shellBinary =
1753
+ resolveExecutableOnPath('powershell.exe')
1754
+ || resolveExecutableOnPath('powershell')
1755
+ || resolveExecutableOnPath('pwsh.exe')
1756
+ || resolveExecutableOnPath('pwsh');
1757
+ if (!shellBinary) {
1758
+ throw new Error('PowerShell is not available to run the official uv installer.');
1759
+ }
1760
+ shellArgs = ['-ExecutionPolicy', 'ByPass', '-File', installerPath];
1761
+ } else {
1762
+ shellBinary = resolveExecutableOnPath('sh');
1763
+ if (!shellBinary) {
1764
+ throw new Error('`sh` is not available to run the official uv installer.');
1765
+ }
1766
+ shellArgs = [installerPath];
1767
+ }
1768
+
1769
+ const installResult = spawnSync(shellBinary, shellArgs, {
1770
+ cwd: repoRoot,
1771
+ stdio: 'inherit',
1772
+ env: installEnv,
1773
+ });
1774
+ if (installResult.error) {
1775
+ throw installResult.error;
1776
+ }
1777
+ if (installResult.status !== 0) {
1778
+ throw new Error(`The official uv installer exited with status ${installResult.status ?? 1}.`);
1779
+ }
1780
+
1781
+ const installedBinary = resolveBinaryReference(runtimeUvBinaryPath(home));
1782
+ if (!installedBinary) {
1783
+ throw new Error(`uv installation finished, but no executable was found under ${binDir}.`);
1784
+ }
1785
+ return installedBinary;
1786
+ }
1787
+
1788
+ function ensureUvBinary(home) {
1789
+ const resolved = resolveUvBinary(home);
1790
+ if (resolved.path) {
1791
+ return resolved.path;
1792
+ }
1793
+ if (resolved.source === 'env' && resolved.configured) {
1794
+ throw new Error(`Configured uv binary could not be resolved: ${resolved.configured}`);
1795
+ }
1796
+ return installLocalUv(home);
1797
+ }
1798
+
1799
+ function buildUvRuntimeEnv(home, extraEnv = {}) {
1800
+ return {
1801
+ ...process.env,
1802
+ UV_CACHE_DIR: runtimeUvCachePath(home),
1803
+ UV_PROJECT_ENVIRONMENT: runtimePythonEnvPath(home),
1804
+ UV_PYTHON_INSTALL_DIR: runtimeUvPythonInstallPath(home),
1805
+ ...extraEnv,
1806
+ };
1807
+ }
1808
+
1809
+ function ensureUvLockPresent() {
1810
+ const lockPath = uvLockPath();
1811
+ if (fs.existsSync(lockPath)) {
1812
+ return lockPath;
1813
+ }
1814
+ console.error('DeepScientist is missing `uv.lock` in the installed package.');
1815
+ console.error('Reinstall the npm package, or from a source checkout run `uv lock` and try again.');
1816
+ process.exit(1);
1817
+ }
1818
+
1819
+ function resolveUvVersion(uvBinary) {
1820
+ const result = runSync(uvBinary, ['--version'], { capture: true, allowFailure: true });
1821
+ if (result.status !== 0) {
1822
+ return null;
1823
+ }
1824
+ return String(result.stdout || '').trim() || null;
1825
+ }
1826
+
1827
+ function ensureUvManagedPython(home, uvBinary, minimumVersionRequest) {
1828
+ ensureDir(runtimeUvPythonInstallPath(home));
1829
+ ensureDir(runtimeUvCachePath(home));
1830
+ step(1, 4, `Provisioning uv-managed Python ${minimumVersionRequest}+`);
1831
+ const installResult = runSync(
1832
+ uvBinary,
1833
+ ['python', 'install', minimumVersionRequest],
1834
+ {
1835
+ allowFailure: true,
1836
+ env: buildUvRuntimeEnv(home),
1837
+ }
1838
+ );
1839
+ if (installResult.status !== 0) {
1840
+ console.error('DeepScientist could not install a uv-managed Python runtime.');
1841
+ process.exit(installResult.status ?? 1);
1842
+ }
1843
+
1844
+ const findResult = runSync(
1845
+ uvBinary,
1846
+ ['python', 'find', '--managed-python', minimumVersionRequest],
1847
+ {
1848
+ capture: true,
1849
+ allowFailure: true,
1850
+ env: buildUvRuntimeEnv(home),
1851
+ }
1852
+ );
1853
+ const managedPython = String(findResult.stdout || '')
1854
+ .trim()
1855
+ .split(/\r?\n/)
1856
+ .filter(Boolean)
1857
+ .pop();
1858
+ if (!managedPython) {
1859
+ console.error('DeepScientist installed uv-managed Python, but could not locate the interpreter afterward.');
1860
+ process.exit(findResult.status ?? 1);
1861
+ }
1862
+ const probe = decoratePythonProbe(probePython(managedPython), 'uv-managed');
1863
+ if (!probe || !pythonMeetsMinimum(probe)) {
1864
+ console.error('DeepScientist found a uv-managed Python, but it does not satisfy the required version.');
1865
+ process.exit(1);
1866
+ }
1867
+ return probe;
1868
+ }
1869
+
1870
+ function syncUvProjectEnvironment(home, uvBinary, pythonTarget, editable) {
1871
+ const args = ['sync', '--frozen', '--no-dev', '--compile-bytecode', '--python', pythonTarget];
1872
+ if (!editable) {
1873
+ args.push('--no-editable');
1874
+ }
1875
+ step(2, 4, 'Syncing locked Python environment');
1876
+ const result = runSync(uvBinary, args, {
1877
+ allowFailure: true,
1878
+ env: buildUvRuntimeEnv(home),
1879
+ });
1880
+ if (result.status === 0) {
1881
+ return;
1882
+ }
1883
+ console.error('DeepScientist could not sync the locked Python environment with uv.');
1884
+ console.error('If you are working from a source checkout, run `uv lock` after dependency changes and try again.');
1885
+ process.exit(result.status ?? 1);
1886
+ }
1887
+
1888
+ function createRuntimeSelectionProbe(runtimeProbe, sourceLabel) {
1889
+ return {
1890
+ ...runtimeProbe,
1891
+ sourceLabel,
1892
+ };
618
1893
  }
619
1894
 
620
1895
  function ensurePythonRuntime(home) {
621
1896
  ensureDir(path.join(home, 'runtime'));
622
1897
  ensureDir(path.join(home, 'runtime', 'bundle'));
623
- const systemPython = resolveSystemPython();
1898
+ ensureDir(runtimeUvCachePath(home));
1899
+ ensureDir(runtimeUvPythonInstallPath(home));
1900
+ ensureDir(runtimeToolsPath(home));
1901
+ let uvBinary;
1902
+ try {
1903
+ uvBinary = ensureUvBinary(home);
1904
+ } catch (error) {
1905
+ printUvInstallGuidance(home, error instanceof Error ? error.message : String(error));
1906
+ process.exit(1);
1907
+ }
1908
+ const runtimePlan = resolvePythonRuntimePlan();
1909
+ if (runtimePlan.runtimeKind === 'managed') {
1910
+ printManagedPythonFallbackNotice({
1911
+ rejectedProbe: runtimePlan.rejectedProbe || null,
1912
+ source: runtimePlan.source,
1913
+ minimumVersionRequest: runtimePlan.minimumVersionRequest,
1914
+ installDir: runtimeUvPythonInstallPath(home),
1915
+ });
1916
+ }
1917
+ const lockPath = ensureUvLockPresent();
624
1918
  const stampPath = path.join(home, 'runtime', 'bundle', 'python-stamp.json');
1919
+ const editable = useEditableProjectInstall();
625
1920
  const desiredStamp = {
1921
+ runtimeManager: 'uv',
626
1922
  version: packageJson.version,
627
1923
  pyprojectHash: sha256File(path.join(repoRoot, 'pyproject.toml')),
1924
+ uvLockHash: sha256File(lockPath),
1925
+ editable,
1926
+ sourceTreeHash: editable ? null : hashPythonSourceTree(),
1927
+ uvVersion: resolveUvVersion(uvBinary),
1928
+ envPath: runtimePythonEnvPath(home),
1929
+ source:
1930
+ runtimePlan.runtimeKind === 'system'
1931
+ ? {
1932
+ kind: 'system',
1933
+ source: runtimePlan.selectedProbe.source,
1934
+ sourceExecutable:
1935
+ runtimePlan.selectedProbe.realExecutable
1936
+ || runtimePlan.selectedProbe.executable
1937
+ || runtimePlan.selectedProbe.binary,
1938
+ sourceVersion: runtimePlan.selectedProbe.version,
1939
+ sourceMajorMinor: pythonMajorMinor(runtimePlan.selectedProbe),
1940
+ }
1941
+ : {
1942
+ kind: 'uv-managed',
1943
+ minimumVersionRequest: runtimePlan.minimumVersionRequest,
1944
+ },
628
1945
  };
629
1946
 
630
1947
  for (let attempt = 0; attempt < 2; attempt += 1) {
631
- const venvPython = venvPythonPath(home);
632
- if (!fs.existsSync(venvPython)) {
633
- recreatePythonRuntime(home, systemPython);
634
- }
635
-
636
- let currentStamp = null;
637
- if (fs.existsSync(stampPath)) {
638
- try {
639
- currentStamp = JSON.parse(fs.readFileSync(stampPath, 'utf8'));
640
- } catch {
641
- currentStamp = null;
1948
+ const runtimePython = runtimePythonPath(home);
1949
+ const currentStamp = readJsonFile(stampPath);
1950
+ const runtimeProbe = fs.existsSync(runtimePython) ? probePython(runtimePython) : null;
1951
+ const runtimeBroken = !runtimeProbe || !runtimeProbe.ok || !pythonMeetsMinimum(runtimeProbe);
1952
+ const stampChanged = JSON.stringify(currentStamp || null) !== JSON.stringify(desiredStamp);
1953
+
1954
+ if (runtimeBroken || stampChanged) {
1955
+ const reason = runtimeBroken
1956
+ ? 'DeepScientist is repairing the local uv-managed Python runtime.'
1957
+ : 'DeepScientist detected a runtime change and is rebuilding the local uv-managed environment.';
1958
+ console.warn(reason);
1959
+ fs.rmSync(stampPath, { force: true });
1960
+ fs.rmSync(runtimePythonEnvPath(home), { recursive: true, force: true });
1961
+
1962
+ let pythonTarget = null;
1963
+ let sourceLabel = null;
1964
+ if (runtimePlan.runtimeKind === 'system' && runtimePlan.selectedProbe) {
1965
+ pythonTarget =
1966
+ runtimePlan.selectedProbe.realExecutable
1967
+ || runtimePlan.selectedProbe.executable
1968
+ || runtimePlan.selectedProbe.binary;
1969
+ sourceLabel = `${runtimePlan.selectedProbe.sourceLabel} via uv-env`;
1970
+ step(1, 4, 'Preparing uv-managed Python runtime');
1971
+ } else {
1972
+ const managedPython = ensureUvManagedPython(home, uvBinary, runtimePlan.minimumVersionRequest);
1973
+ pythonTarget = managedPython.realExecutable || managedPython.executable || managedPython.binary;
1974
+ sourceLabel = managedPython.sourceLabel;
642
1975
  }
643
- }
644
1976
 
645
- if (!currentStamp || currentStamp.version !== desiredStamp.version || currentStamp.pyprojectHash !== desiredStamp.pyprojectHash) {
646
- step(2, 4, 'Installing Python package and dependencies');
647
- installPythonBundle(venvPython);
1977
+ syncUvProjectEnvironment(home, uvBinary, pythonTarget, editable);
648
1978
  fs.writeFileSync(stampPath, `${JSON.stringify(desiredStamp, null, 2)}\n`, 'utf8');
1979
+ const syncedProbe = fs.existsSync(runtimePython) ? probePython(runtimePython) : null;
1980
+ if (syncedProbe && syncedProbe.ok && pythonMeetsMinimum(syncedProbe) && verifyPythonRuntime(runtimePython)) {
1981
+ fs.rmSync(legacyVenvRootPath(home), { recursive: true, force: true });
1982
+ return {
1983
+ runtimePython,
1984
+ uvBinary,
1985
+ runtimeManager: 'uv',
1986
+ runtimeProbe: createRuntimeSelectionProbe(syncedProbe, sourceLabel || 'uv-managed'),
1987
+ sourcePython: runtimePlan.selectedProbe || null,
1988
+ };
1989
+ }
649
1990
  }
650
1991
 
651
- if (verifyPythonRuntime(venvPython)) {
652
- return venvPython;
1992
+ if (runtimeProbe && runtimeProbe.ok && pythonMeetsMinimum(runtimeProbe) && verifyPythonRuntime(runtimePython)) {
1993
+ fs.rmSync(legacyVenvRootPath(home), { recursive: true, force: true });
1994
+ return {
1995
+ runtimePython,
1996
+ uvBinary,
1997
+ runtimeManager: 'uv',
1998
+ runtimeProbe: createRuntimeSelectionProbe(
1999
+ runtimeProbe,
2000
+ runtimePlan.runtimeKind === 'system' && runtimePlan.selectedProbe
2001
+ ? `${runtimePlan.selectedProbe.sourceLabel} via uv-env`
2002
+ : 'uv-managed'
2003
+ ),
2004
+ sourcePython: runtimePlan.selectedProbe || null,
2005
+ };
653
2006
  }
654
2007
 
655
- console.warn('DeepScientist is repairing the local Python runtime...');
2008
+ console.warn('DeepScientist is retrying the local uv-managed Python runtime repair.');
656
2009
  fs.rmSync(stampPath, { force: true });
657
- fs.rmSync(venvRootPath(home), { recursive: true, force: true });
2010
+ fs.rmSync(runtimePythonEnvPath(home), { recursive: true, force: true });
658
2011
  }
659
2012
 
660
- console.error('DeepScientist could not prepare a healthy local Python runtime.');
2013
+ console.error('DeepScientist could not prepare a healthy uv-managed Python runtime.');
661
2014
  process.exit(1);
662
2015
  }
663
2016
 
664
- function runPythonCli(venvPython, args, options = {}) {
665
- return runSync(venvPython, ['-m', 'deepscientist.cli', ...args], options);
2017
+ function runPythonCli(runtimePython, args, options = {}) {
2018
+ const env = {
2019
+ ...process.env,
2020
+ DEEPSCIENTIST_REPO_ROOT: repoRoot,
2021
+ ...(options.env || {}),
2022
+ };
2023
+ return runSync(runtimePython, ['-m', 'deepscientist.cli', ...args], { ...options, env });
666
2024
  }
667
2025
 
668
2026
  function normalizePythonCliArgs(args, home) {
@@ -673,12 +2031,15 @@ function normalizePythonCliArgs(args, home) {
673
2031
  index += 1;
674
2032
  continue;
675
2033
  }
2034
+ if (arg === '--hero' || arg === '--here') {
2035
+ continue;
2036
+ }
676
2037
  normalized.push(arg);
677
2038
  }
678
2039
  return ['--home', home, ...normalized];
679
2040
  }
680
2041
 
681
- function ensureInitialized(home, venvPython) {
2042
+ function ensureInitialized(home, runtimePython) {
682
2043
  const stampPath = path.join(home, 'runtime', 'bundle', 'init-stamp.json');
683
2044
  let currentStamp = null;
684
2045
  if (fs.existsSync(stampPath)) {
@@ -703,7 +2064,7 @@ function ensureInitialized(home, venvPython) {
703
2064
  return;
704
2065
  }
705
2066
  step(3, 4, 'Preparing DeepScientist home, config, skills, and Git checks');
706
- const result = runPythonCli(venvPython, ['--home', home, 'init'], { capture: true, allowFailure: true });
2067
+ const result = runPythonCli(runtimePython, ['--home', home, 'init'], { capture: true, allowFailure: true });
707
2068
  const stdout = result.stdout || '';
708
2069
  let payload = {};
709
2070
  try {
@@ -762,34 +2123,11 @@ function resolveExecutableOnPath(commandName) {
762
2123
  return null;
763
2124
  }
764
2125
  const directories = pathValue.split(path.delimiter).filter(Boolean);
765
- const extensions =
766
- process.platform === 'win32'
767
- ? (process.env.PATHEXT || '.EXE;.CMD;.BAT;.COM')
768
- .split(';')
769
- .filter(Boolean)
770
- : [''];
771
2126
  for (const directory of directories) {
772
2127
  const base = path.join(directory, commandName);
773
- for (const extension of extensions) {
774
- const candidate = process.platform === 'win32' ? `${base}${extension}` : base;
775
- try {
776
- if (!fs.existsSync(candidate)) {
777
- continue;
778
- }
779
- const stat = fs.statSync(candidate);
780
- if (!stat.isFile()) {
781
- continue;
782
- }
783
- if (process.platform !== 'win32') {
784
- try {
785
- fs.accessSync(candidate, fs.constants.X_OK);
786
- } catch {
787
- continue;
788
- }
789
- }
2128
+ for (const candidate of candidateExecutablePaths(base)) {
2129
+ if (isExecutableFile(candidate)) {
790
2130
  return candidate;
791
- } catch {
792
- continue;
793
2131
  }
794
2132
  }
795
2133
  }
@@ -1102,9 +2440,469 @@ async function stopDaemon(home) {
1102
2440
  console.log('DeepScientist daemon stopped.');
1103
2441
  }
1104
2442
 
1105
- async function readConfiguredUiAddress(home, venvPython, fallbackHost, fallbackPort) {
2443
+ function writeUpdateLog(home, content) {
2444
+ const logPath = path.join(home, 'logs', 'update.log');
2445
+ ensureDir(path.dirname(logPath));
2446
+ fs.appendFileSync(logPath, `${content.replace(/\s+$/, '')}\n`, 'utf8');
2447
+ return logPath;
2448
+ }
2449
+
2450
+ function summarizeUpdateFailure(result) {
2451
+ const lines = [];
2452
+ if (result.error) {
2453
+ lines.push(result.error);
2454
+ }
2455
+ if (result.stderr) {
2456
+ lines.push(String(result.stderr).trim());
2457
+ }
2458
+ if (result.stdout) {
2459
+ lines.push(String(result.stdout).trim());
2460
+ }
2461
+ return lines.filter(Boolean).join('\n').trim() || 'Unknown update failure.';
2462
+ }
2463
+
2464
+ function runNpmInstallLatest(home, npmBinary) {
2465
+ const args = ['install', '-g', `${UPDATE_PACKAGE_NAME}@latest`, '--no-audit', '--no-fund'];
2466
+ const startedAt = new Date().toISOString();
2467
+ const result = spawnSync(npmBinary, args, {
2468
+ encoding: 'utf8',
2469
+ env: process.env,
2470
+ timeout: 15 * 60 * 1000,
2471
+ });
2472
+ const finishedAt = new Date().toISOString();
2473
+ const logPath = writeUpdateLog(
2474
+ home,
2475
+ [
2476
+ `=== ${startedAt} installing ${UPDATE_PACKAGE_NAME}@latest ===`,
2477
+ `$ ${npmBinary} ${args.join(' ')}`,
2478
+ String(result.stdout || '').trim(),
2479
+ String(result.stderr || '').trim(),
2480
+ `exit=${result.status ?? 'null'} error=${result.error ? result.error.message : 'none'}`,
2481
+ `=== finished ${finishedAt} ===`,
2482
+ '',
2483
+ ].join('\n')
2484
+ );
2485
+ return {
2486
+ ok: !result.error && result.status === 0,
2487
+ stdout: String(result.stdout || ''),
2488
+ stderr: String(result.stderr || ''),
2489
+ error: result.error ? result.error.message : null,
2490
+ status: result.status ?? null,
2491
+ logPath,
2492
+ };
2493
+ }
2494
+
2495
+ async function promptUpdateAction(status) {
2496
+ const options = [
2497
+ {
2498
+ value: 'update',
2499
+ label: status.can_self_update ? 'Update now' : 'Show manual update',
2500
+ },
2501
+ { value: 'later', label: 'Remind me later' },
2502
+ { value: 'skip', label: 'Skip this version' },
2503
+ ];
2504
+ if (!process.stdin.isTTY || !process.stdout.isTTY) {
2505
+ return 'later';
2506
+ }
2507
+ return new Promise((resolve) => {
2508
+ let selected = 1;
2509
+ const lines = [
2510
+ '',
2511
+ 'A new DeepScientist version is available.',
2512
+ '',
2513
+ `Current: ${status.current_version}`,
2514
+ `Latest: ${status.latest_version || 'unknown'}`,
2515
+ '',
2516
+ status.can_self_update
2517
+ ? 'What do you want to do?'
2518
+ : 'Self-update is not available for this installation. Choose an action:',
2519
+ ];
2520
+
2521
+ const cleanup = () => {
2522
+ process.stdin.off('keypress', onKeypress);
2523
+ if (process.stdin.isTTY) {
2524
+ process.stdin.setRawMode(false);
2525
+ }
2526
+ process.stdin.pause();
2527
+ console.log('');
2528
+ };
2529
+
2530
+ const render = () => {
2531
+ console.clear();
2532
+ for (const line of lines) {
2533
+ console.log(line);
2534
+ }
2535
+ for (let index = 0; index < options.length; index += 1) {
2536
+ const option = options[index];
2537
+ console.log(`${index === selected ? '>' : ' '} ${option.label}`);
2538
+ }
2539
+ console.log('');
2540
+ console.log('Use ↑/↓ and Enter.');
2541
+ };
2542
+
2543
+ const onKeypress = (_str, key) => {
2544
+ if (key?.name === 'up') {
2545
+ selected = (selected - 1 + options.length) % options.length;
2546
+ render();
2547
+ return;
2548
+ }
2549
+ if (key?.name === 'down') {
2550
+ selected = (selected + 1) % options.length;
2551
+ render();
2552
+ return;
2553
+ }
2554
+ if (key?.name === 'return') {
2555
+ const choice = options[selected].value;
2556
+ cleanup();
2557
+ resolve(choice);
2558
+ return;
2559
+ }
2560
+ if (key?.ctrl && key?.name === 'c') {
2561
+ cleanup();
2562
+ resolve('later');
2563
+ }
2564
+ };
2565
+
2566
+ readline.emitKeypressEvents(process.stdin);
2567
+ process.stdin.setRawMode(true);
2568
+ process.stdin.resume();
2569
+ process.stdin.on('keypress', onKeypress);
2570
+ render();
2571
+ });
2572
+ }
2573
+
2574
+ function printUpdateStatus(status, { compact = false } = {}) {
2575
+ if (compact) {
2576
+ if (status.update_available) {
2577
+ console.log(
2578
+ `DeepScientist update available: ${status.current_version} -> ${status.latest_version}`
2579
+ );
2580
+ if (status.can_self_update) {
2581
+ console.log('Run `ds update --yes` to install it.');
2582
+ } else {
2583
+ console.log(`Manual update: ${status.manual_update_command}`);
2584
+ }
2585
+ return;
2586
+ }
2587
+ console.log(`DeepScientist is up to date (${status.current_version}).`);
2588
+ return;
2589
+ }
2590
+
2591
+ console.log('DeepScientist update status');
2592
+ renderKeyValueRows([
2593
+ ['Current', status.current_version],
2594
+ ['Latest', status.latest_version || 'unknown'],
2595
+ ['Available', status.update_available ? 'yes' : 'no'],
2596
+ ['Install mode', status.install_mode],
2597
+ ['Self-update', status.can_self_update ? 'supported' : 'manual-only'],
2598
+ ['Last checked', status.last_checked_at || 'never'],
2599
+ ]);
2600
+ if (status.last_check_error) {
2601
+ console.log('');
2602
+ console.log(`Version check error: ${status.last_check_error}`);
2603
+ }
2604
+ if (!status.can_self_update) {
2605
+ console.log('');
2606
+ console.log(`Manual update command: ${status.manual_update_command}`);
2607
+ if (status.reason) {
2608
+ console.log(status.reason);
2609
+ }
2610
+ }
2611
+ const npmBinary = resolveNpmBinary();
2612
+ if (!npmBinary) {
2613
+ return {
2614
+ ok: false,
2615
+ updated: false,
2616
+ status,
2617
+ message: '`npm` is not available on PATH.',
2618
+ };
2619
+ }
2620
+ }
2621
+
2622
+ function spawnDetachedNode(args, options = {}) {
2623
+ const out = options.logPath ? fs.openSync(options.logPath, 'a') : 'ignore';
2624
+ const child = spawn(process.execPath, args, {
2625
+ cwd: options.cwd || repoRoot,
2626
+ detached: true,
2627
+ stdio: ['ignore', out, out],
2628
+ env: options.env || process.env,
2629
+ });
2630
+ child.unref();
2631
+ return child;
2632
+ }
2633
+
2634
+ async function restartIntoUpdatedLauncher(rawArgs) {
2635
+ const launcherPath = resolveLauncherPath();
2636
+ if (!launcherPath) {
2637
+ throw new Error('Could not resolve the DeepScientist launcher after the update.');
2638
+ }
2639
+ const args = [launcherPath, '--skip-update-check', ...rawArgs.filter((item) => item !== '--skip-update-check')];
2640
+ const child = spawn(process.execPath, args, {
2641
+ cwd: repoRoot,
2642
+ stdio: 'inherit',
2643
+ env: process.env,
2644
+ });
2645
+ await new Promise((resolve, reject) => {
2646
+ child.on('error', reject);
2647
+ child.on('exit', (code) => {
2648
+ process.exit(code ?? 0);
2649
+ resolve();
2650
+ });
2651
+ });
2652
+ }
2653
+
2654
+ async function performSelfUpdate(home, options = {}) {
2655
+ const status = checkForUpdates(home, { force: true });
2656
+ if (!status.update_available) {
2657
+ return {
2658
+ ok: true,
2659
+ updated: false,
2660
+ status,
2661
+ message: `DeepScientist is already on the latest version (${status.current_version}).`,
2662
+ };
2663
+ }
2664
+ if (!status.can_self_update) {
2665
+ return {
2666
+ ok: false,
2667
+ updated: false,
2668
+ status,
2669
+ message: status.reason || `Manual update required: ${status.manual_update_command}`,
2670
+ };
2671
+ }
2672
+
2673
+ const daemonState = readDaemonState(home);
2674
+ const configuredUi = readConfiguredUiAddressFromFile(home, options.host, options.port);
2675
+ const host = options.host || daemonState?.host || configuredUi.host;
2676
+ const port = options.port || daemonState?.port || configuredUi.port;
2677
+ const targetVersion = status.latest_version;
2678
+
2679
+ mergeUpdateState(home, {
2680
+ current_version: status.current_version,
2681
+ latest_version: targetVersion,
2682
+ target_version: targetVersion,
2683
+ busy: true,
2684
+ last_update_started_at: new Date().toISOString(),
2685
+ last_update_result: null,
2686
+ });
2687
+
2688
+ try {
2689
+ if (daemonState?.pid || daemonState?.daemon_id) {
2690
+ await stopDaemon(home);
2691
+ }
2692
+ } catch (error) {
2693
+ mergeUpdateState(home, {
2694
+ busy: false,
2695
+ last_update_finished_at: new Date().toISOString(),
2696
+ last_update_result: {
2697
+ ok: false,
2698
+ target_version: targetVersion,
2699
+ message: error instanceof Error ? error.message : String(error),
2700
+ },
2701
+ });
2702
+ return {
2703
+ ok: false,
2704
+ updated: false,
2705
+ status,
2706
+ message: error instanceof Error ? error.message : String(error),
2707
+ };
2708
+ }
2709
+
2710
+ const installResult = runNpmInstallLatest(home, npmBinary);
2711
+ if (!installResult.ok) {
2712
+ const message = summarizeUpdateFailure(installResult);
2713
+ mergeUpdateState(home, {
2714
+ busy: false,
2715
+ last_update_finished_at: new Date().toISOString(),
2716
+ last_update_result: {
2717
+ ok: false,
2718
+ target_version: targetVersion,
2719
+ message,
2720
+ log_path: installResult.logPath,
2721
+ },
2722
+ });
2723
+ return {
2724
+ ok: false,
2725
+ updated: false,
2726
+ status,
2727
+ message,
2728
+ log_path: installResult.logPath,
2729
+ };
2730
+ }
2731
+
2732
+ const restartDaemon =
2733
+ options.restartDaemon === true
2734
+ || (options.restartDaemon !== false && Boolean(daemonState?.pid || daemonState?.daemon_id));
2735
+ if (restartDaemon) {
2736
+ const launcherPath = resolveLauncherPath();
2737
+ if (!launcherPath) {
2738
+ const message = 'DeepScientist was updated, but the new launcher path could not be resolved for daemon restart.';
2739
+ mergeUpdateState(home, {
2740
+ busy: false,
2741
+ last_update_finished_at: new Date().toISOString(),
2742
+ last_update_result: {
2743
+ ok: false,
2744
+ target_version: targetVersion,
2745
+ message,
2746
+ log_path: installResult.logPath,
2747
+ },
2748
+ });
2749
+ return {
2750
+ ok: false,
2751
+ updated: true,
2752
+ status,
2753
+ message,
2754
+ log_path: installResult.logPath,
2755
+ };
2756
+ }
2757
+ spawnDetachedNode(
2758
+ [
2759
+ launcherPath,
2760
+ '--home',
2761
+ home,
2762
+ '--host',
2763
+ String(host),
2764
+ '--port',
2765
+ String(port),
2766
+ '--daemon-only',
2767
+ '--no-browser',
2768
+ '--skip-update-check',
2769
+ ],
2770
+ {
2771
+ cwd: repoRoot,
2772
+ env: process.env,
2773
+ logPath: path.join(home, 'logs', 'daemon-restart.log'),
2774
+ }
2775
+ );
2776
+ }
2777
+
2778
+ mergeUpdateState(home, {
2779
+ busy: false,
2780
+ current_version: targetVersion,
2781
+ latest_version: targetVersion,
2782
+ target_version: null,
2783
+ last_checked_at: new Date().toISOString(),
2784
+ last_check_error: null,
2785
+ last_update_finished_at: new Date().toISOString(),
2786
+ last_update_result: {
2787
+ ok: true,
2788
+ target_version: targetVersion,
2789
+ message: restartDaemon
2790
+ ? `DeepScientist updated to ${targetVersion}. The daemon is restarting.`
2791
+ : `DeepScientist updated to ${targetVersion}.`,
2792
+ log_path: installResult.logPath,
2793
+ },
2794
+ });
2795
+
2796
+ return {
2797
+ ok: true,
2798
+ updated: true,
2799
+ status: buildUpdateStatus(home),
2800
+ message: restartDaemon
2801
+ ? `DeepScientist updated to ${targetVersion}. The daemon is restarting.`
2802
+ : `DeepScientist updated to ${targetVersion}.`,
2803
+ log_path: installResult.logPath,
2804
+ };
2805
+ }
2806
+
2807
+ async function maybeHandleStartupUpdate(home, rawArgs, options = {}) {
2808
+ if (options.skipUpdateCheck || process.env.DS_SKIP_UPDATE_PROMPT === '1') {
2809
+ return false;
2810
+ }
2811
+ const status = checkForUpdates(home, { force: false });
2812
+ if (!status.update_available) {
2813
+ return false;
2814
+ }
2815
+ if (!status.prompt_recommended) {
2816
+ return false;
2817
+ }
2818
+
2819
+ if (!process.stdin.isTTY || !process.stdout.isTTY) {
2820
+ printUpdateStatus(status, { compact: true });
2821
+ mergeUpdateState(home, {
2822
+ last_prompted_at: new Date().toISOString(),
2823
+ });
2824
+ return false;
2825
+ }
2826
+
2827
+ const action = await promptUpdateAction(status);
2828
+ if (action === 'later') {
2829
+ markUpdateDeferred(home, status.latest_version);
2830
+ return false;
2831
+ }
2832
+ if (action === 'skip') {
2833
+ markUpdateSkipped(home, status.latest_version);
2834
+ return false;
2835
+ }
2836
+ if (action === 'update' && !status.can_self_update) {
2837
+ console.log(`Manual update required: ${status.manual_update_command}`);
2838
+ if (status.reason) {
2839
+ console.log(status.reason);
2840
+ }
2841
+ markUpdateDeferred(home, status.latest_version);
2842
+ return false;
2843
+ }
2844
+ if (action === 'update') {
2845
+ console.log(`Updating DeepScientist ${status.current_version} -> ${status.latest_version} ...`);
2846
+ const result = await performSelfUpdate(home, { restartDaemon: false });
2847
+ if (!result.ok) {
2848
+ console.error(result.message);
2849
+ return false;
2850
+ }
2851
+ console.log(result.message);
2852
+ await restartIntoUpdatedLauncher(rawArgs);
2853
+ return true;
2854
+ }
2855
+ return false;
2856
+ }
2857
+
2858
+ async function startBackgroundUpdateWorker(home, options = {}) {
2859
+ const launcherPath = resolveLauncherPath();
2860
+ if (!launcherPath) {
2861
+ return {
2862
+ ok: false,
2863
+ started: false,
2864
+ message: 'Could not resolve the launcher path for the background update worker.',
2865
+ };
2866
+ }
2867
+ const status = checkForUpdates(home, { force: false });
2868
+ mergeUpdateState(home, {
2869
+ current_version: status.current_version,
2870
+ latest_version: status.latest_version,
2871
+ target_version: status.latest_version,
2872
+ busy: true,
2873
+ last_update_started_at: new Date().toISOString(),
2874
+ last_update_result: null,
2875
+ });
2876
+ const workerArgs = [
2877
+ launcherPath,
2878
+ 'update',
2879
+ '--yes',
2880
+ '--worker',
2881
+ '--home',
2882
+ home,
2883
+ '--host',
2884
+ String(options.host || '0.0.0.0'),
2885
+ '--port',
2886
+ String(options.port || 20999),
2887
+ '--restart-daemon',
2888
+ '--skip-update-check',
2889
+ ];
2890
+ spawnDetachedNode(workerArgs, {
2891
+ cwd: repoRoot,
2892
+ env: process.env,
2893
+ logPath: path.join(home, 'logs', 'update-worker.log'),
2894
+ });
2895
+ return {
2896
+ ok: true,
2897
+ started: true,
2898
+ message: 'DeepScientist update worker started.',
2899
+ status: buildUpdateStatus(home),
2900
+ };
2901
+ }
2902
+
2903
+ async function readConfiguredUiAddress(home, runtimePython, fallbackHost, fallbackPort) {
1106
2904
  try {
1107
- const result = runPythonCli(venvPython, ['--home', home, 'config', 'show', 'config'], { capture: true, allowFailure: true });
2905
+ const result = runPythonCli(runtimePython, ['--home', home, 'config', 'show', 'config'], { capture: true, allowFailure: true });
1108
2906
  const text = result.stdout || '';
1109
2907
  const hostMatch = text.match(/^\s*host:\s*["']?([^"'\n]+)["']?\s*$/m);
1110
2908
  const portMatch = text.match(/^\s*port:\s*(\d+)\s*$/m);
@@ -1158,7 +2956,7 @@ function readConfiguredUiAddressFromFile(home, fallbackHost, fallbackPort) {
1158
2956
  }
1159
2957
  }
1160
2958
 
1161
- async function startDaemon(home, venvPython, host, port) {
2959
+ async function startDaemon(home, runtimePython, host, port) {
1162
2960
  const browserUrl = browserUiUrl(host, port);
1163
2961
  const daemonBindUrl = bindUiUrl(host, port);
1164
2962
  const state = readDaemonState(home);
@@ -1182,10 +2980,10 @@ async function startDaemon(home, venvPython, host, port) {
1182
2980
  removeDaemonState(home);
1183
2981
  }
1184
2982
 
1185
- const bootstrapState = readCodexBootstrapState(home, venvPython);
2983
+ const bootstrapState = readCodexBootstrapState(home, runtimePython);
1186
2984
  if (!bootstrapState.codex_ready) {
1187
2985
  console.log('Codex is not marked ready yet. Running startup probe...');
1188
- const probe = probeCodexBootstrap(home, venvPython);
2986
+ const probe = probeCodexBootstrap(home, runtimePython);
1189
2987
  if (!probe || probe.ok !== true) {
1190
2988
  throw createCodexPreflightError(home, probe);
1191
2989
  }
@@ -1198,7 +2996,7 @@ async function startDaemon(home, venvPython, host, port) {
1198
2996
  const out = fs.openSync(logPath, 'a');
1199
2997
  const daemonId = crypto.randomUUID();
1200
2998
  const child = spawn(
1201
- venvPython,
2999
+ runtimePython,
1202
3000
  ['-m', 'deepscientist.cli', '--home', home, 'daemon', '--host', host, '--port', String(port)],
1203
3001
  {
1204
3002
  cwd: repoRoot,
@@ -1206,11 +3004,11 @@ async function startDaemon(home, venvPython, host, port) {
1206
3004
  stdio: ['ignore', out, out],
1207
3005
  env: {
1208
3006
  ...process.env,
3007
+ DEEPSCIENTIST_REPO_ROOT: repoRoot,
3008
+ DEEPSCIENTIST_NODE_BINARY: process.execPath,
3009
+ DEEPSCIENTIST_LAUNCHER_PATH: path.join(repoRoot, 'bin', 'ds.js'),
1209
3010
  DS_DAEMON_ID: daemonId,
1210
3011
  DS_DAEMON_MANAGED_BY: 'ds-launcher',
1211
- PYTHONPATH: process.env.PYTHONPATH
1212
- ? `${srcPath}${path.delimiter}${process.env.PYTHONPATH}`
1213
- : srcPath,
1214
3012
  },
1215
3013
  }
1216
3014
  );
@@ -1304,7 +3102,7 @@ function handleCodexPreflightFailure(error) {
1304
3102
  return true;
1305
3103
  }
1306
3104
 
1307
- function launchTui(url, questId, home, venvPython) {
3105
+ function launchTui(url, questId, home, runtimePython) {
1308
3106
  const entry = ensureNodeBundle('src/tui', 'dist/index.js');
1309
3107
  const args = [entry, '--base-url', url];
1310
3108
  if (questId) {
@@ -1316,8 +3114,8 @@ function launchTui(url, questId, home, venvPython) {
1316
3114
  env: {
1317
3115
  ...process.env,
1318
3116
  DEEPSCIENTIST_TUI_HOME: home,
1319
- DEEPSCIENTIST_TUI_PYTHON: venvPython,
1320
- DEEPSCIENTIST_VENV_PYTHON: venvPython,
3117
+ DEEPSCIENTIST_TUI_PYTHON: runtimePython,
3118
+ DEEPSCIENTIST_RUNTIME_PYTHON: runtimePython,
1321
3119
  },
1322
3120
  });
1323
3121
  child.on('exit', (code) => {
@@ -1325,6 +3123,258 @@ function launchTui(url, questId, home, venvPython) {
1325
3123
  });
1326
3124
  }
1327
3125
 
3126
+ async function updateMain(rawArgs) {
3127
+ const options = parseUpdateArgs(rawArgs);
3128
+ if (!options) {
3129
+ printUpdateHelp();
3130
+ process.exit(1);
3131
+ }
3132
+ if (options.help) {
3133
+ printUpdateHelp();
3134
+ process.exit(0);
3135
+ }
3136
+
3137
+ const home = options.home || resolveHome(rawArgs);
3138
+ ensureDir(home);
3139
+
3140
+ if (options.background && options.yes && !options.worker) {
3141
+ const payload = await startBackgroundUpdateWorker(home, {
3142
+ host: options.host,
3143
+ port: options.port,
3144
+ });
3145
+ if (options.json) {
3146
+ console.log(JSON.stringify(payload, null, 2));
3147
+ } else {
3148
+ console.log(payload.message);
3149
+ }
3150
+ process.exit(payload.ok ? 0 : 1);
3151
+ }
3152
+
3153
+ const status = checkForUpdates(home, { force: options.forceCheck || options.check || options.yes || options.worker });
3154
+
3155
+ if (options.remindLater) {
3156
+ const payload = markUpdateDeferred(home, status.latest_version);
3157
+ if (options.json) {
3158
+ console.log(JSON.stringify(payload, null, 2));
3159
+ } else {
3160
+ console.log(`DeepScientist will remind you later about ${payload.latest_version || 'the next release'}.`);
3161
+ }
3162
+ process.exit(0);
3163
+ }
3164
+
3165
+ if (options.skipVersion) {
3166
+ const payload = markUpdateSkipped(home, status.latest_version);
3167
+ if (options.json) {
3168
+ console.log(JSON.stringify(payload, null, 2));
3169
+ } else {
3170
+ console.log(`DeepScientist will stop prompting for ${payload.last_skipped_version || payload.latest_version || 'this release'}.`);
3171
+ }
3172
+ process.exit(0);
3173
+ }
3174
+
3175
+ if (options.worker) {
3176
+ const payload = await performSelfUpdate(home, {
3177
+ host: options.host,
3178
+ port: options.port,
3179
+ restartDaemon: options.restartDaemon,
3180
+ });
3181
+ if (options.json) {
3182
+ console.log(JSON.stringify(payload, null, 2));
3183
+ } else {
3184
+ console.log(payload.message);
3185
+ }
3186
+ process.exit(payload.ok ? 0 : 1);
3187
+ }
3188
+
3189
+ if (options.yes) {
3190
+ const payload = await performSelfUpdate(home, {
3191
+ host: options.host,
3192
+ port: options.port,
3193
+ restartDaemon: options.restartDaemon,
3194
+ });
3195
+ if (options.json) {
3196
+ console.log(JSON.stringify(payload, null, 2));
3197
+ } else {
3198
+ console.log(payload.message);
3199
+ }
3200
+ process.exit(payload.ok ? 0 : 1);
3201
+ }
3202
+
3203
+ if (options.check || options.json) {
3204
+ if (options.json) {
3205
+ console.log(JSON.stringify(status, null, 2));
3206
+ } else {
3207
+ printUpdateStatus(status);
3208
+ }
3209
+ process.exit(0);
3210
+ }
3211
+
3212
+ if (!status.update_available) {
3213
+ printUpdateStatus(status, { compact: true });
3214
+ process.exit(0);
3215
+ }
3216
+
3217
+ if (!process.stdin.isTTY || !process.stdout.isTTY) {
3218
+ printUpdateStatus(status, { compact: true });
3219
+ process.exit(0);
3220
+ }
3221
+
3222
+ const action = await promptUpdateAction(status);
3223
+ if (action === 'later') {
3224
+ markUpdateDeferred(home, status.latest_version);
3225
+ console.log('Update reminder deferred.');
3226
+ process.exit(0);
3227
+ }
3228
+ if (action === 'skip') {
3229
+ markUpdateSkipped(home, status.latest_version);
3230
+ console.log(`Skipped ${status.latest_version}.`);
3231
+ process.exit(0);
3232
+ }
3233
+ if (!status.can_self_update) {
3234
+ console.log(`Manual update command: ${status.manual_update_command}`);
3235
+ if (status.reason) {
3236
+ console.log(status.reason);
3237
+ }
3238
+ process.exit(0);
3239
+ }
3240
+ const payload = await performSelfUpdate(home, {
3241
+ host: options.host,
3242
+ port: options.port,
3243
+ restartDaemon: options.restartDaemon,
3244
+ });
3245
+ console.log(payload.message);
3246
+ process.exit(payload.ok ? 0 : 1);
3247
+ }
3248
+
3249
+ async function migrateMain(rawArgs) {
3250
+ const options = parseMigrateArgs(rawArgs);
3251
+ if (!options) {
3252
+ printMigrateHelp();
3253
+ process.exit(1);
3254
+ }
3255
+ if (options.help) {
3256
+ printMigrateHelp();
3257
+ process.exit(0);
3258
+ }
3259
+
3260
+ const sourceHome = realpathOrSelf(options.home || resolveHome(rawArgs));
3261
+ const targetHome = path.resolve(options.target);
3262
+ if (!fs.existsSync(sourceHome)) {
3263
+ console.error(`DeepScientist source path does not exist: ${sourceHome}`);
3264
+ process.exit(1);
3265
+ }
3266
+ if (isPathEqual(sourceHome, targetHome)) {
3267
+ console.error('DeepScientist source and target paths are identical. Choose a different migration target.');
3268
+ process.exit(1);
3269
+ }
3270
+ if (isPathInside(targetHome, sourceHome) || isPathInside(sourceHome, targetHome)) {
3271
+ console.error('DeepScientist migration requires two separate sibling paths. Do not nest one path inside the other.');
3272
+ process.exit(1);
3273
+ }
3274
+ if (fs.existsSync(targetHome)) {
3275
+ console.error(`DeepScientist target path already exists: ${targetHome}`);
3276
+ process.exit(1);
3277
+ }
3278
+
3279
+ printMigrationSummary({ sourceHome, targetHome, restart: options.restart });
3280
+ if (!options.yes) {
3281
+ const confirmed = await promptMigrationConfirmation({ sourceHome, targetHome });
3282
+ if (!confirmed) {
3283
+ console.log('DeepScientist migration cancelled.');
3284
+ process.exit(1);
3285
+ }
3286
+ }
3287
+
3288
+ const state = readDaemonState(sourceHome);
3289
+ const configured = readConfiguredUiAddressFromFile(sourceHome);
3290
+ const url = state?.url || browserUiUrl(configured.host, configured.port);
3291
+ const health = await fetchHealth(url);
3292
+ if (state || healthMatchesHome({ health, home: sourceHome })) {
3293
+ await stopDaemon(sourceHome);
3294
+ } else if (health && health.status === 'ok') {
3295
+ console.log(`Skipping daemon stop because ${url} belongs to another DeepScientist home.`);
3296
+ }
3297
+
3298
+ const pythonRuntime = ensurePythonRuntime(sourceHome);
3299
+ const runtimePython = pythonRuntime.runtimePython;
3300
+ const result = runPythonCli(
3301
+ runtimePython,
3302
+ ['--home', sourceHome, 'migrate', targetHome],
3303
+ { capture: true, allowFailure: true }
3304
+ );
3305
+ let payload = null;
3306
+ try {
3307
+ payload = JSON.parse(String(result.stdout || '{}'));
3308
+ } catch {
3309
+ payload = null;
3310
+ }
3311
+ if (result.status !== 0 || !payload || payload.ok !== true) {
3312
+ if (result.stdout) {
3313
+ process.stdout.write(result.stdout);
3314
+ if (!String(result.stdout).endsWith('\n')) {
3315
+ process.stdout.write('\n');
3316
+ }
3317
+ }
3318
+ if (result.stderr) {
3319
+ process.stderr.write(result.stderr);
3320
+ if (!String(result.stderr).endsWith('\n')) {
3321
+ process.stderr.write('\n');
3322
+ }
3323
+ }
3324
+ console.error('DeepScientist migration failed.');
3325
+ process.exit(result.status ?? 1);
3326
+ }
3327
+
3328
+ repairMigratedInstallWrappers(targetHome);
3329
+ const rewrittenWrappers = rewriteLauncherWrappersIfPointingAtSource({ sourceHome, targetHome });
3330
+
3331
+ const sourceContainsCurrentInstall = isPathEqual(repoRoot, path.join(sourceHome, 'cli')) || isPathInside(repoRoot, sourceHome);
3332
+ if (sourceContainsCurrentInstall) {
3333
+ scheduleDeferredSourceCleanup({ sourceHome, targetHome });
3334
+ } else {
3335
+ fs.rmSync(sourceHome, { recursive: true, force: true });
3336
+ }
3337
+
3338
+ let restartMessage = 'Restart skipped.';
3339
+ if (options.restart) {
3340
+ const migratedLauncher = path.join(targetHome, 'cli', 'bin', 'ds.js');
3341
+ if (!fs.existsSync(migratedLauncher)) {
3342
+ restartMessage = `Migration succeeded, but restart was skipped because the migrated launcher is missing: ${migratedLauncher}`;
3343
+ } else {
3344
+ const child = spawn(
3345
+ process.execPath,
3346
+ [migratedLauncher, '--home', targetHome, '--daemon-only', '--no-browser', '--skip-update-check'],
3347
+ {
3348
+ cwd: path.join(targetHome, 'cli'),
3349
+ detached: true,
3350
+ stdio: 'ignore',
3351
+ env: process.env,
3352
+ }
3353
+ );
3354
+ child.unref();
3355
+ restartMessage = 'Managed daemon restart scheduled from the migrated home.';
3356
+ }
3357
+ }
3358
+
3359
+ console.log('');
3360
+ console.log('DeepScientist migration completed.');
3361
+ console.log(`New home: ${targetHome}`);
3362
+ if (payload.summary) {
3363
+ console.log(payload.summary);
3364
+ }
3365
+ if (rewrittenWrappers.length > 0) {
3366
+ console.log(`Updated wrappers: ${rewrittenWrappers.join(', ')}`);
3367
+ }
3368
+ console.log(restartMessage);
3369
+ if (sourceContainsCurrentInstall) {
3370
+ console.log(`Old path cleanup has been scheduled: ${sourceHome}`);
3371
+ } else {
3372
+ console.log(`Old path removed: ${sourceHome}`);
3373
+ }
3374
+ console.log(`Use \`ds --home ${targetHome}\` if you want to override the default explicitly.`);
3375
+ process.exit(0);
3376
+ }
3377
+
1328
3378
  async function launcherMain(rawArgs) {
1329
3379
  const options = parseLauncherArgs(rawArgs);
1330
3380
  if (!options) {
@@ -1368,11 +3418,15 @@ async function launcherMain(rawArgs) {
1368
3418
  process.exit(healthy && (!state || identityMatch) ? 0 : 1);
1369
3419
  }
1370
3420
 
1371
- const venvPython = ensurePythonRuntime(home);
1372
- ensureInitialized(home, venvPython);
3421
+ const pythonRuntime = ensurePythonRuntime(home);
3422
+ const runtimePython = pythonRuntime.runtimePython;
3423
+ ensureInitialized(home, runtimePython);
3424
+ if (await maybeHandleStartupUpdate(home, rawArgs, options)) {
3425
+ return true;
3426
+ }
1373
3427
  maybePrintOptionalLatexNotice(home);
1374
3428
 
1375
- const configuredUi = await readConfiguredUiAddress(home, venvPython, options.host, options.port);
3429
+ const configuredUi = await readConfiguredUiAddress(home, runtimePython, options.host, options.port);
1376
3430
  const host = configuredUi.host;
1377
3431
  const port = configuredUi.port;
1378
3432
  const mode = normalizeMode(options.mode ?? 'web');
@@ -1388,7 +3442,7 @@ async function launcherMain(rawArgs) {
1388
3442
  step(4, 4, 'Starting local daemon and UI surfaces');
1389
3443
  let started;
1390
3444
  try {
1391
- started = await startDaemon(home, venvPython, host, port);
3445
+ started = await startDaemon(home, runtimePython, host, port);
1392
3446
  } catch (error) {
1393
3447
  if (handleCodexPreflightFailure(error)) return true;
1394
3448
  throw error;
@@ -1401,6 +3455,8 @@ async function launcherMain(rawArgs) {
1401
3455
  autoOpenRequested: shouldOpenBrowser,
1402
3456
  browserOpened,
1403
3457
  daemonOnly: options.daemonOnly,
3458
+ home,
3459
+ pythonSelection: pythonRuntime.runtimeProbe,
1404
3460
  });
1405
3461
 
1406
3462
  if (options.daemonOnly) {
@@ -1409,13 +3465,21 @@ async function launcherMain(rawArgs) {
1409
3465
  if (mode === 'web') {
1410
3466
  process.exit(0);
1411
3467
  }
1412
- launchTui(started.url, options.questId, home, venvPython);
3468
+ launchTui(started.url, options.questId, home, runtimePython);
1413
3469
  return true;
1414
3470
  }
1415
3471
 
1416
3472
  async function main() {
1417
3473
  const args = process.argv.slice(2);
1418
3474
  const positional = findFirstPositionalArg(args);
3475
+ if (positional && positional.value === 'update') {
3476
+ await updateMain(args);
3477
+ return;
3478
+ }
3479
+ if (positional && positional.value === 'migrate') {
3480
+ await migrateMain(args);
3481
+ return;
3482
+ }
1419
3483
  if (args.length === 0 || args[0] === 'ui' || (!positional && args[0]?.startsWith('--'))) {
1420
3484
  await launcherMain(args);
1421
3485
  return;
@@ -1426,15 +3490,16 @@ async function main() {
1426
3490
  }
1427
3491
  if (positional && pythonCommands.has(positional.value)) {
1428
3492
  const home = resolveHome(args);
1429
- const venvPython = ensurePythonRuntime(home);
3493
+ const pythonRuntime = ensurePythonRuntime(home);
3494
+ const runtimePython = pythonRuntime.runtimePython;
1430
3495
  if (positional.value === 'run' || positional.value === 'daemon') {
1431
3496
  maybePrintOptionalLatexNotice(home);
1432
3497
  }
1433
3498
  if (positional.value === 'run' || positional.value === 'daemon') {
1434
- const bootstrapState = readCodexBootstrapState(home, venvPython);
3499
+ const bootstrapState = readCodexBootstrapState(home, runtimePython);
1435
3500
  if (!bootstrapState.codex_ready) {
1436
3501
  try {
1437
- const probe = probeCodexBootstrap(home, venvPython);
3502
+ const probe = probeCodexBootstrap(home, runtimePython);
1438
3503
  if (!probe || probe.ok !== true) {
1439
3504
  throw createCodexPreflightError(home, probe);
1440
3505
  }
@@ -1444,14 +3509,35 @@ async function main() {
1444
3509
  }
1445
3510
  }
1446
3511
  }
1447
- const result = runPythonCli(venvPython, normalizePythonCliArgs(args, home), { allowFailure: true });
3512
+ const result = runPythonCli(runtimePython, normalizePythonCliArgs(args, home), { allowFailure: true });
1448
3513
  process.exit(result.status ?? 0);
1449
3514
  return;
1450
3515
  }
1451
3516
  await launcherMain(args);
1452
3517
  }
1453
3518
 
1454
- main().catch((error) => {
1455
- console.error(error instanceof Error ? error.message : String(error));
1456
- process.exit(1);
1457
- });
3519
+ module.exports = {
3520
+ __internal: {
3521
+ minimumPythonRequest,
3522
+ createPythonRuntimePlan,
3523
+ buildUvRuntimeEnv,
3524
+ runtimePythonEnvPath,
3525
+ runtimePythonPath,
3526
+ runtimeUvBinaryPath,
3527
+ legacyVenvRootPath,
3528
+ resolveUvBinary,
3529
+ resolveHome,
3530
+ parseMigrateArgs,
3531
+ useEditableProjectInstall,
3532
+ compareVersions,
3533
+ detectInstallMode,
3534
+ buildUpdateStatus,
3535
+ },
3536
+ };
3537
+
3538
+ if (require.main === module) {
3539
+ main().catch((error) => {
3540
+ console.error(error instanceof Error ? error.message : String(error));
3541
+ process.exit(1);
3542
+ });
3543
+ }