@tasksai/install 0.1.39 → 0.1.40

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 (74) hide show
  1. package/README.md +34 -0
  2. package/bootstrap/Install RealtorTasksAI.command +37 -0
  3. package/bootstrap/Install RealtorTasksAI.ps1 +42 -0
  4. package/package.json +7 -2
  5. package/runtime/document_renderer.py +882 -0
  6. package/runtime/server.py +55 -581
  7. package/runtime/software_use.py +39 -0
  8. package/runtime/workflow-requirements.txt +5 -0
  9. package/runtime/workflows/__init__.py +0 -0
  10. package/runtime/workflows/account_snapshot.py +33 -0
  11. package/runtime/workflows/attachments.py +45 -0
  12. package/runtime/workflows/authority_guides.py +81 -0
  13. package/runtime/workflows/catalog.py +51 -0
  14. package/runtime/workflows/catalog_app.py +174 -0
  15. package/runtime/workflows/catalog_generation.py +186 -0
  16. package/runtime/workflows/catalog_output.py +312 -0
  17. package/runtime/workflows/catalog_suggestions.py +51 -0
  18. package/runtime/workflows/catalog_workspace.py +152 -0
  19. package/runtime/workflows/cli.py +66 -0
  20. package/runtime/workflows/document_answers.py +96 -0
  21. package/runtime/workflows/document_selection.py +50 -0
  22. package/runtime/workflows/documents.py +243 -0
  23. package/runtime/workflows/embedded/catalog.html +83 -0
  24. package/runtime/workflows/embedded/offer-review.html +516 -0
  25. package/runtime/workflows/embedded/preview.html +36 -0
  26. package/runtime/workflows/embedded_actions.py +96 -0
  27. package/runtime/workflows/embedded_demo.py +424 -0
  28. package/runtime/workflows/folders.py +120 -0
  29. package/runtime/workflows/gateway.py +157 -0
  30. package/runtime/workflows/generation_lock.py +26 -0
  31. package/runtime/workflows/launcher.py +61 -0
  32. package/runtime/workflows/licensed_delivery.py +46 -0
  33. package/runtime/workflows/mcp_dev.py +52 -0
  34. package/runtime/workflows/meeting_plan.py +92 -0
  35. package/runtime/workflows/model_client.py +26 -0
  36. package/runtime/workflows/numeric_consistency.py +72 -0
  37. package/runtime/workflows/public_source_fetch.py +66 -0
  38. package/runtime/workflows/realtor/__init__.py +0 -0
  39. package/runtime/workflows/realtor/adapter.py +179 -0
  40. package/runtime/workflows/realtor/seller_offer/ORIGIN.json +16 -0
  41. package/runtime/workflows/realtor/seller_offer/__init__.py +0 -0
  42. package/runtime/workflows/realtor/seller_offer/examples/demo-input.json +414 -0
  43. package/runtime/workflows/realtor/seller_offer/examples/make_demo.py +44 -0
  44. package/runtime/workflows/realtor/seller_offer/references/input-contract.md +65 -0
  45. package/runtime/workflows/realtor/seller_offer/references/source-boundaries.md +16 -0
  46. package/runtime/workflows/realtor/seller_offer/scripts/__init__.py +0 -0
  47. package/runtime/workflows/realtor/seller_offer/scripts/build_workbook.mjs +233 -0
  48. package/runtime/workflows/realtor/seller_offer/scripts/build_workbook.py +163 -0
  49. package/runtime/workflows/realtor/seller_offer/scripts/offer_engine.py +370 -0
  50. package/runtime/workflows/realtor/seller_offer/scripts/presentation.py +52 -0
  51. package/runtime/workflows/realtor/seller_offer/scripts/render_outputs.py +228 -0
  52. package/runtime/workflows/realtor/seller_offer/scripts/run_package.py +95 -0
  53. package/runtime/workflows/realtor/seller_offer/tests/test_engine.py +252 -0
  54. package/runtime/workflows/realtor/seller_offer/tests/test_workbook.mjs +28 -0
  55. package/runtime/workflows/realtor-release-registry.json +705 -0
  56. package/runtime/workflows/released_catalog.py +91 -0
  57. package/runtime/workflows/source_capture.py +82 -0
  58. package/runtime/workflows/store.py +492 -0
  59. package/runtime/workflows/table_calculations.py +132 -0
  60. package/runtime/workflows/template.py +65 -0
  61. package/runtime/workflows/workspace_launch.py +76 -0
  62. package/src/index.js +205 -118
  63. package/src/managed-python.js +63 -0
  64. package/src/operation-lock.js +22 -0
  65. package/src/prepared-update.js +86 -0
  66. package/src/private-workflow.js +116 -0
  67. package/src/python-runtime.js +50 -0
  68. package/src/recover-installation.js +30 -0
  69. package/src/recovery-lock.js +30 -0
  70. package/src/software-hash.js +24 -0
  71. package/src/software-use.js +32 -0
  72. package/src/update-journal.js +24 -0
  73. package/src/update-recovery.js +72 -0
  74. package/src/workspace-runtime.js +45 -0
@@ -0,0 +1,65 @@
1
+ """Shared presentation and input contract, independent of hosting and exporters.
2
+
3
+ Only server-owned definitions may select brands and outputs. This module does
4
+ not select a tenant, authorize access, generate content, or set credit prices.
5
+ """
6
+ from dataclasses import dataclass
7
+ from .store import JobError
8
+
9
+ BRANDS = {
10
+ "realtor": {"name": "RealtorTasksAI", "accent": "#347462"},
11
+ "law": {"name": "LawTasksAI", "accent": "#375b88"},
12
+ "farmer": {"name": "FarmerTasksAI", "accent": "#657a39"},
13
+ }
14
+
15
+ @dataclass(frozen=True)
16
+ class InputField:
17
+ key: str
18
+ label: str
19
+ required: bool = True
20
+
21
+ @dataclass(frozen=True)
22
+ class OutputFile:
23
+ kind: str
24
+ label: str
25
+
26
+ @dataclass(frozen=True)
27
+ class WorkflowTemplate:
28
+ workflow_id: str
29
+ vertical: str
30
+ title: str
31
+ inputs: tuple[InputField, ...] | None
32
+ outputs: tuple[OutputFile, ...]
33
+
34
+ def __post_init__(self):
35
+ if self.vertical not in BRANDS:
36
+ raise JobError("Unknown workflow vertical")
37
+ kinds = [output.kind for output in self.outputs]
38
+ if "word" not in kinds or len(kinds) != len(set(kinds)) or set(kinds) - {"word", "excel"}:
39
+ raise JobError("Every workflow needs one Word document; Excel is optional")
40
+ keys = [field.key for field in (self.inputs or ())]
41
+ if len(keys) != len(set(keys)):
42
+ raise JobError("Input names must be unique")
43
+
44
+ def input_state(self, answers=None):
45
+ if self.inputs is None:
46
+ raise JobError("This workflow uses its own structured input adapter")
47
+ answers = {} if answers is None else answers
48
+ known = {field.key for field in self.inputs}
49
+ if not isinstance(answers, dict) or set(answers) - known:
50
+ raise JobError("Answers must match this workflow's inputs")
51
+ if any(not isinstance(value, str) for value in answers.values()):
52
+ raise JobError("Answers must be text")
53
+ values = {field.key: answers.get(field.key, "").strip() for field in self.inputs}
54
+ missing = [{"key": field.key, "label": field.label} for field in self.inputs
55
+ if field.required and not values[field.key]]
56
+ return {"answers": values, "missing": missing, "ready": not missing}
57
+
58
+ def presentation(self):
59
+ return {"workflow_id": self.workflow_id, "brand": dict(BRANDS[self.vertical]),
60
+ "title": self.title, "outputs": [{"kind": out.kind, "label": out.label} for out in self.outputs]}
61
+
62
+ SELLER_OFFER = WorkflowTemplate("realtor.seller_offer", "realtor", "Offer comparison", None, (
63
+ OutputFile("word", "Download Word briefing"), OutputFile("excel", "Download Excel comparison")))
64
+ # None delegates input validation to the existing structured offer adapter.
65
+ # An empty tuple represents a workflow requiring no inputs.
@@ -0,0 +1,76 @@
1
+ """Start or reuse the installed local workspace after licensed delivery."""
2
+ import hashlib
3
+ from contextlib import contextmanager
4
+ import json
5
+ import os
6
+ from pathlib import Path
7
+ import subprocess
8
+ import sys
9
+ import time
10
+ from urllib.parse import urlparse
11
+ from urllib.request import urlopen
12
+ from .licensed_delivery import safe_directory
13
+ from .store import JobError
14
+
15
+
16
+ def workspace_identity(root,owner):
17
+ return hashlib.sha256((str(Path(root).resolve())+'\n'+owner).encode()).hexdigest()
18
+
19
+
20
+ def runtime_fingerprint():
21
+ runtime=Path(__file__).resolve().parents[1]
22
+ paths=[runtime/'document_renderer.py',runtime/'software_use.py']+sorted((runtime/'workflows').rglob('*.py'))+sorted((runtime/'workflows'/'embedded').glob('*.html'))+[runtime/'workflows'/'realtor-release-registry.json']
23
+ return hashlib.sha256(json.dumps({str(p.relative_to(runtime)):hashlib.sha256(p.read_bytes()).hexdigest() for p in paths},sort_keys=True).encode()).hexdigest()
24
+
25
+
26
+ def existing_url(root,owner):
27
+ try:
28
+ record=json.loads((Path(root)/'workspace-connection.json').read_text(encoding="utf-8"));url=record['url'];parsed=urlparse(url)
29
+ if parsed.scheme!='http' or parsed.hostname!='127.0.0.1' or not parsed.port or not parsed.path.startswith('/workspace/') or parsed.query or parsed.fragment:return None
30
+ with urlopen(url+'/health',timeout=1) as response: health=json.load(response)
31
+ return url if not health.get('closing') and health.get('workspace_id')==workspace_identity(root,owner) and health.get('runtime_fingerprint')==runtime_fingerprint() else None
32
+ except (OSError,ValueError,KeyError):return None
33
+
34
+
35
+ def _launch(root,owner):
36
+ root=safe_directory(root)
37
+ url=existing_url(root,owner)
38
+ if url:return url
39
+ # Each spawned app uses a distinct ephemeral port. Concurrent starts cannot
40
+ # overwrite customer data; a fresh health check only accepts this workspace.
41
+ runtime=Path(__file__).resolve().parents[1]
42
+ env={**os.environ,'PYTHONPATH':str(runtime)+os.pathsep+os.environ.get('PYTHONPATH','')}
43
+ flags={'creationflags':subprocess.CREATE_NO_WINDOW} if os.name=='nt' else {'start_new_session':True}
44
+ log=root/'workspace-server.log'
45
+ if log.is_symlink():raise JobError('Workspace log must not be a symbolic link')
46
+ with log.open('ab') as output:
47
+ process=subprocess.Popen([sys.executable,'-m','workflows.catalog_app','--root',str(root),'--owner',owner],cwd=runtime,env=env,stdin=subprocess.DEVNULL,stdout=output,stderr=output,**flags)
48
+ for _ in range(50):
49
+ url=existing_url(root,owner)
50
+ if url:return url
51
+ if process.poll() is not None:break
52
+ time.sleep(.1)
53
+ raise JobError('The local workspace could not start; the delivered skill remains available')
54
+
55
+
56
+ @contextmanager
57
+ def startup_lock(root):
58
+ path=root/'workspace-startup.lock'
59
+ if path.is_symlink():raise JobError('Workspace lock must not be a symbolic link')
60
+ with path.open('a+b') as stream:
61
+ if os.name=='nt':
62
+ import msvcrt
63
+ if path.stat().st_size==0:stream.write(b'0');stream.flush()
64
+ stream.seek(0);msvcrt.locking(stream.fileno(),msvcrt.LK_LOCK,1)
65
+ else:
66
+ import fcntl
67
+ fcntl.flock(stream.fileno(),fcntl.LOCK_EX)
68
+ try:yield
69
+ finally:
70
+ if os.name=='nt':stream.seek(0);msvcrt.locking(stream.fileno(),msvcrt.LK_UNLCK,1)
71
+ else:fcntl.flock(stream.fileno(),fcntl.LOCK_UN)
72
+
73
+
74
+ def launch(root,owner):
75
+ root=safe_directory(root)
76
+ with startup_lock(root):return _launch(root,owner)
package/src/index.js CHANGED
@@ -1,8 +1,10 @@
1
1
  #!/usr/bin/env node
2
2
 
3
3
  import fs from "node:fs";
4
+ import { requirePython, ensurePython, setPythonInstallRoot } from "./python-runtime.js";
5
+ import { withLock } from "./operation-lock.js";
4
6
  import fsp from "node:fs/promises";
5
- import { randomUUID } from "node:crypto";
7
+ import { randomUUID, createHash } from "node:crypto";
6
8
  import https from "node:https";
7
9
  import os from "node:os";
8
10
  import path from "node:path";
@@ -123,18 +125,31 @@ function isMainModule() {
123
125
  }
124
126
 
125
127
  async function main() {
128
+ if (process.argv[2] === "private-workflow") {
129
+ const { installPrivateWorkflow } = await import("./private-workflow.js");
130
+ await installPrivateWorkflow(process.argv.slice(3));
131
+ return;
132
+ }
126
133
  const options = parseArgs(process.argv.slice(2));
127
134
 
128
135
  if (!options.productId) {
129
136
  printUsage();
130
137
  process.exit(1);
131
138
  }
139
+ setPythonInstallRoot(getInstallDir(options.productId, options));
132
140
 
133
141
  if (options.command === "doctor") {
134
142
  await doctor(options);
135
143
  return;
136
144
  }
137
145
 
146
+ if (options.command === "recover") {
147
+ const { recoverInstallation } = await import("./recover-installation.js");
148
+ const result = await recoverInstallation(getInstallDir(options.productId, options));
149
+ console.log(result.status === "not_needed" ? "No unfinished software update was found." : "Previous TasksAI software restored. Restart your AI app, then run the loader doctor check before resuming work. Recovery copies have been retained.");
150
+ return;
151
+ }
152
+
138
153
  if (options.command === "update") {
139
154
  await install(options, { updateOnly: true });
140
155
  return;
@@ -187,7 +202,7 @@ function parseArgs(argv) {
187
202
 
188
203
  options.productId = positionals[0] || null;
189
204
  if (positionals[1]) options.command = positionals[1];
190
- if (!["install", "doctor", "update", "uninstall"].includes(options.command)) {
205
+ if (!["install", "doctor", "update", "uninstall", "recover"].includes(options.command)) {
191
206
  throw new Error(`Unsupported command: ${options.command}`);
192
207
  }
193
208
  if (!["browser", "license-key"].includes(options.auth)) {
@@ -205,6 +220,7 @@ function printUsage() {
205
220
  tasksai-install <product-id> [install] [--source <repo-url>] [--ref <branch>] [--client claude-desktop|cursor|windsurf|codex|all] [--auth browser|license-key] [--install-dir <path>]
206
221
  tasksai-install <product-id> doctor [--client claude-desktop|cursor|windsurf|codex|all] [--install-dir <path>]
207
222
  tasksai-install <product-id> update [--install-dir <path>]
223
+ tasksai-install <product-id> recover [--install-dir <path>]
208
224
  tasksai-install <product-id> uninstall [--client claude-desktop|cursor|windsurf|codex|all] [--install-dir <path>]
209
225
 
210
226
  Examples:
@@ -239,58 +255,79 @@ async function install(options, { updateOnly = false } = {}) {
239
255
  const clients = updateOnly ? [] : resolveClients(options.client);
240
256
  await preflightWriteAccess({ operation: updateOnly ? "update" : "install", installDir, clients, options, vertical, source });
241
257
 
242
- await fsp.mkdir(runtimeDir, { recursive: true });
243
- await fsp.mkdir(path.join(installDir, "logs"), { recursive: true });
244
- await logEvent(installDir, "install_start", {
245
- productId: options.productId,
246
- source: source.repoUrl,
247
- client: options.client,
248
- updateOnly
249
- });
250
-
251
- await writeJson(path.join(installDir, "agent-install.json"), manifest);
252
- await writeJson(path.join(installDir, "vertical.json"), vertical);
253
- await downloadRuntime(source, runtimeDir, manifest);
254
-
255
- const licenseKey = await resolveLicenseKey(options, vertical, installDir);
256
- const existingEnvPath = path.join(installDir, ".env");
257
- const existingEnv = fs.existsSync(existingEnvPath)
258
- ? parseEnv(await fsp.readFile(existingEnvPath, "utf8"))
259
- : {};
260
- const installId = firstValue(existingEnv.TASKSAI_INSTALL_ID, process.env.TASKSAI_INSTALL_ID) || randomUUID();
261
-
262
- if (!options.skipPythonDeps) {
263
- installPythonDeps(runtimeDir, vendorDir);
264
- }
258
+ await fsp.mkdir(installDir, { recursive: true });
259
+ return withLock(path.join(installDir, ".installer-operation.lock"), async () => {
260
+ await fsp.mkdir(runtimeDir, { recursive: true });
261
+ await fsp.mkdir(path.join(installDir, "logs"), { recursive: true });
262
+ await logEvent(installDir, "install_start", {
263
+ productId: options.productId,
264
+ source: source.repoUrl,
265
+ client: options.client,
266
+ updateOnly
267
+ });
265
268
 
266
- const envEntries = {
267
- TASKSAI_LICENSE_KEY: licenseKey,
268
- LAWTASKSAI_LICENSE_KEY: licenseKey,
269
- TASKSAI_PRODUCT_ID: vertical.product_id,
270
- TASKSAI_API_BASE: vertical.api_base_url,
271
- LAWTASKSAI_API_BASE: vertical.api_base_url,
272
- TASKSAI_INSTALL_ID: installId
273
- };
274
- await writeEnvFile(path.join(installDir, ".env"), envEntries);
275
- await writeEnvFile(path.join(runtimeDir, ".env"), envEntries);
269
+ const licenseKey = await resolveLicenseKey(options, vertical, installDir);
270
+ await ensurePython(installDir);
271
+ const existingEnvPath = path.join(installDir, ".env");
272
+ const existingEnv = fs.existsSync(existingEnvPath)
273
+ ? parseEnv(await fsp.readFile(existingEnvPath, "utf8"))
274
+ : {};
275
+ const installId = firstValue(existingEnv.TASKSAI_INSTALL_ID, process.env.TASKSAI_INSTALL_ID) || randomUUID();
276
+
277
+ const envEntries = {
278
+ TASKSAI_LICENSE_KEY: licenseKey,
279
+ LAWTASKSAI_LICENSE_KEY: licenseKey,
280
+ TASKSAI_PRODUCT_ID: vertical.product_id,
281
+ TASKSAI_API_BASE: vertical.api_base_url,
282
+ LAWTASKSAI_API_BASE: vertical.api_base_url,
283
+ TASKSAI_INSTALL_ID: installId
284
+ };
285
+ if (manifest.local_workspace === true && vertical.product_id === "realtor") {
286
+ const accountScope = createHash("sha256").update(licenseKey).digest("hex").slice(0, 24);
287
+ envEntries.TASKSAI_WORKSPACE_DIR = path.join(installDir, "workspaces", accountScope);
288
+ }
289
+ const { preparedUpdate } = await import("./prepared-update.js");
290
+ const prepared = await preparedUpdate(installDir, {
291
+ skipDependencies: options.skipPythonDeps,
292
+ installDependencies: installPythonDeps,
293
+ validate: async (stagedRuntime, stagedVendor) => {
294
+ if (!options.skipPythonDeps) await verifyRuntimeHealth({
295
+ serverPath: path.join(stagedRuntime, "server.py"), vendorDir: stagedVendor,
296
+ isolated: true, workspace: manifest.local_workspace === true
297
+ });
298
+ if (updateOnly && !options.skipPythonDeps) await verifyUpdateClientPython({
299
+ productId: vertical.product_id, serverPath: path.join(stagedRuntime, "server.py"),
300
+ vendorDir: stagedVendor, workspace: manifest.local_workspace === true
301
+ });
302
+ },
303
+ prepare: async (staging, stagedRuntime) => {
304
+ await downloadRuntime(source, stagedRuntime, manifest);
305
+ await writeJson(path.join(staging, "agent-install.json"), manifest);
306
+ await writeJson(path.join(staging, "vertical.json"), vertical);
307
+ await writeEnvFile(path.join(staging, ".env"), envEntries);
308
+ await writeEnvFile(path.join(stagedRuntime, ".env"), envEntries);
309
+ }
310
+ });
311
+ await logEvent(installDir, "software_prepared", { previousSoftware: prepared.previousSoftware });
276
312
 
277
- if (updateOnly) {
278
- await logEvent(installDir, "update_complete", { productId: options.productId });
279
- console.log(`${vertical.display_name} runtime updated at ${installDir}`);
280
- return;
281
- }
313
+ if (updateOnly) {
314
+ await logEvent(installDir, "update_complete", { productId: options.productId });
315
+ console.log(`${vertical.display_name} runtime updated at ${installDir}`);
316
+ return;
317
+ }
282
318
 
283
- for (const client of clients) {
284
- await configureMcpClient({ client, vertical, installDir, runtimeDir, vendorDir });
285
- }
319
+ for (const client of clients) {
320
+ await configureMcpClient({ client, vertical, installDir, runtimeDir, vendorDir });
321
+ }
286
322
 
287
- await doctor(options, { quietSuccess: true });
288
- await logEvent(installDir, "install_complete", {
289
- productId: options.productId,
290
- clients: clients.map((client) => client.id)
323
+ await doctor(options, { quietSuccess: true });
324
+ await logEvent(installDir, "install_complete", {
325
+ productId: options.productId,
326
+ clients: clients.map((client) => client.id)
327
+ });
328
+ console.log(`${vertical.display_name} is installed. Restart ${clients.map((client) => client.displayName).join(", ")} to see the tools.`);
329
+ console.log(`After restart, ask: "${vertical.first_prompt}"`);
291
330
  });
292
- console.log(`${vertical.display_name} is installed. Restart ${clients.map((client) => client.displayName).join(", ")} to see the tools.`);
293
- console.log(`After restart, ask: "${vertical.first_prompt}"`);
294
331
  }
295
332
 
296
333
  async function doctor(options, {
@@ -308,6 +345,7 @@ async function doctor(options, {
308
345
  const clients = clientsOverride || resolveClients(options.client);
309
346
 
310
347
  const problems = [];
348
+ const configuredCommands = new Map();
311
349
  if (!fs.existsSync(verticalPath)) problems.push(`Missing ${verticalPath}`);
312
350
  if (!fs.existsSync(envPath)) problems.push(`Missing ${envPath}`);
313
351
  if (!fs.existsSync(serverPath)) problems.push(`Missing ${serverPath}`);
@@ -322,6 +360,11 @@ async function doctor(options, {
322
360
  ? await codexConfigHasServer(configPath, vertical.product_id)
323
361
  : await jsonConfigHasServer(configPath, vertical.product_id);
324
362
  if (!hasServer) problems.push(`${client.displayName} config does not contain mcpServers.${vertical.product_id}`);
363
+ else {
364
+ const command = await configuredPythonCommand(configPath, client.configFormat, vertical.product_id);
365
+ if (!command) problems.push(`${client.displayName} Python connection could not be verified; rerun the installer for this client`);
366
+ else configuredCommands.set(command, client.displayName);
367
+ }
325
368
  } else {
326
369
  problems.push(`${client.displayName} config not found at ${configPath}`);
327
370
  }
@@ -333,6 +376,13 @@ async function doctor(options, {
333
376
  } catch (error) {
334
377
  problems.push(`Runtime health check failed (${healthErrorSummary(error)})`);
335
378
  }
379
+ for (const [python, clientName] of configuredCommands) {
380
+ try {
381
+ await runtimeHealthImpl({ serverPath, vendorDir, python });
382
+ } catch (error) {
383
+ problems.push(`${clientName} configured Python connection failed (${healthErrorSummary(error)}); rerun the installer for this client`);
384
+ }
385
+ }
336
386
  }
337
387
 
338
388
  const productId = String(vertical.product_id || options.productId || "").trim().toLowerCase();
@@ -425,42 +475,45 @@ async function uninstall(options) {
425
475
  const installDir = getInstallDir(options.productId, options);
426
476
  const clients = resolveClients(options.client);
427
477
  await preflightWriteAccess({ operation: "uninstall", installDir, clients, options });
478
+ await fsp.mkdir(installDir, { recursive: true });
479
+ return withLock(path.join(installDir, ".installer-operation.lock"), async () => {
428
480
 
429
- for (const client of clients) {
430
- const configPath = client.configPath();
431
- if (!fs.existsSync(configPath)) {
432
- console.log(`${client.displayName} config not found; nothing to remove.`);
433
- continue;
434
- }
481
+ for (const client of clients) {
482
+ const configPath = client.configPath();
483
+ if (!fs.existsSync(configPath)) {
484
+ console.log(`${client.displayName} config not found; nothing to remove.`);
485
+ continue;
486
+ }
435
487
 
436
- await withLock(`${configPath}.lock`, async () => {
437
- const keys = mcpServerKeysForProduct(options.productId);
438
- if (client.configFormat === "toml") {
439
- const text = await fsp.readFile(configPath, "utf8");
440
- if (!tomlHasAnyMcpServer(text, keys)) {
441
- console.log(`${client.displayName} does not have a ${options.productId} MCP entry.`);
442
- return;
443
- }
444
- await backupFile(configPath);
445
- await atomicWriteText(configPath, removeTomlSections(text, tomlSectionNamesForProduct(options.productId)));
446
- } else {
447
- const config = await readJson(configPath);
448
- if (!keys.some((key) => config.mcpServers?.[key])) {
449
- console.log(`${client.displayName} does not have a ${options.productId} MCP entry.`);
450
- return;
488
+ await withLock(`${configPath}.lock`, async () => {
489
+ const keys = mcpServerKeysForProduct(options.productId);
490
+ if (client.configFormat === "toml") {
491
+ const text = await fsp.readFile(configPath, "utf8");
492
+ if (!tomlHasAnyMcpServer(text, keys)) {
493
+ console.log(`${client.displayName} does not have a ${options.productId} MCP entry.`);
494
+ return;
495
+ }
496
+ await backupFile(configPath);
497
+ await atomicWriteText(configPath, removeTomlSections(text, tomlSectionNamesForProduct(options.productId)));
498
+ } else {
499
+ const config = await readJson(configPath);
500
+ if (!keys.some((key) => config.mcpServers?.[key])) {
501
+ console.log(`${client.displayName} does not have a ${options.productId} MCP entry.`);
502
+ return;
503
+ }
504
+ await backupFile(configPath);
505
+ for (const key of keys) delete config.mcpServers[key];
506
+ await atomicWriteJson(configPath, config);
451
507
  }
452
- await backupFile(configPath);
453
- for (const key of keys) delete config.mcpServers[key];
454
- await atomicWriteJson(configPath, config);
455
- }
456
- });
508
+ });
457
509
 
458
- console.log(`${options.productId} was removed from ${client.displayName}.`);
459
- }
510
+ console.log(`${options.productId} was removed from ${client.displayName}.`);
511
+ }
460
512
 
461
- await logEvent(installDir, "uninstall_complete", {
462
- productId: options.productId,
463
- clients: clients.map((client) => client.id)
513
+ await logEvent(installDir, "uninstall_complete", {
514
+ productId: options.productId,
515
+ clients: clients.map((client) => client.id)
516
+ });
464
517
  });
465
518
  }
466
519
 
@@ -487,7 +540,7 @@ async function configureMcpClient({ client, vertical, installDir, runtimeDir, ve
487
540
 
488
541
  function buildMcpServerConfig({ client, vertical, installDir, runtimeDir, vendorDir }) {
489
542
  return {
490
- command: pythonCommandForPlatform(),
543
+ command: requirePython(),
491
544
  args: [path.join(runtimeDir, "server.py")],
492
545
  env: {
493
546
  TASKSAI_PRODUCT_ID: vertical.product_id,
@@ -513,6 +566,57 @@ function pythonVendorPaths(vendorDir, platform = process.platform) {
513
566
  ];
514
567
  }
515
568
 
569
+ async function configuredPythonCommand(configPath, format, productId) {
570
+ let command;
571
+ if (format === "toml") {
572
+ let selected = false;
573
+ const commands = [];
574
+ for (const line of (await fsp.readFile(configPath, "utf8")).split(/\r?\n/)) {
575
+ const section = line.match(/^\s*\[([^\]]+)\]\s*(?:#.*)?$/);
576
+ if (section) { selected = section[1] === `mcp_servers.${productId}`; continue; }
577
+ if (!selected) continue;
578
+ const match = line.match(/^\s*command\s*=\s*("(?:\\.|[^"\\])*"|'[^']*')\s*(?:#.*)?$/);
579
+ if (match) {
580
+ try { commands.push(match[1].startsWith("'") ? match[1].slice(1, -1) : JSON.parse(match[1])); }
581
+ catch { return null; }
582
+ }
583
+ }
584
+ if (commands.length !== 1) return null;
585
+ command = commands[0];
586
+ } else {
587
+ command = (await readJson(configPath)).mcpServers?.[productId]?.command;
588
+ }
589
+ // Inspect direct Python connections only. Do not execute arbitrary wrappers
590
+ // from an unrecognized client configuration as part of a health check.
591
+ if (typeof command !== "string" || !/^(?:python(?:3(?:\.\d+)?)?)(?:\.exe)?$/i.test(command.split(/[\\/]/).pop())) return null;
592
+ return command;
593
+ }
594
+
595
+ async function verifyUpdateClientPython({ productId, serverPath, vendorDir, workspace,
596
+ clients = Object.values(CLIENTS), runtimeHealthImpl = verifyRuntimeHealth }) {
597
+ // Check every existing connection for this product, including legacy names.
598
+ // Read only: unrelated connections and user configuration are never rewritten.
599
+ const verified = new Set();
600
+ for (const client of clients) {
601
+ const configPath = client.configPath();
602
+ if (!fs.existsSync(configPath)) continue;
603
+ for (const key of mcpServerKeysForProduct(productId)) {
604
+ const present = client.configFormat === "toml"
605
+ ? await codexConfigHasServer(configPath, key) : await jsonConfigHasServer(configPath, key);
606
+ if (!present) continue;
607
+ const python = await configuredPythonCommand(configPath, client.configFormat, key);
608
+ if (!python) throw new Error(`${client.displayName} connection cannot be checked before updating; rerun the installer for this client. The previous software remains in place.`);
609
+ if (verified.has(python)) continue;
610
+ try {
611
+ await runtimeHealthImpl({ serverPath, vendorDir, python, isolated: true, workspace });
612
+ } catch (error) {
613
+ throw new Error(`${client.displayName} connection cannot run the updated software (${healthErrorSummary(error)}); rerun the installer for this client. The previous software remains in place.`);
614
+ }
615
+ verified.add(python);
616
+ }
617
+ }
618
+ }
619
+
516
620
  async function jsonConfigHasServer(configPath, productId) {
517
621
  const config = await readJson(configPath);
518
622
  return Boolean(config.mcpServers?.[productId]);
@@ -768,10 +872,17 @@ async function downloadRuntime(source, runtimeDir, manifest = {}) {
768
872
  const useBundledRuntime = manifest.runtime?.source === "shared_installer";
769
873
  const serverText = await loadRuntimeText(source, "server.py", { useBundledRuntime });
770
874
  const matcherText = await loadRuntimeText(source, "skill_matcher.py", { useBundledRuntime });
875
+ const rendererText = await loadRuntimeText(source, "document_renderer.py", { useBundledRuntime });
771
876
  const requirementsText = await loadRuntimeText(source, "requirements.txt", { useBundledRuntime });
877
+ await fsp.copyFile(path.join(BUNDLED_RUNTIME_DIR, "software_use.py"), path.join(runtimeDir, "software_use.py"));
772
878
  await fsp.writeFile(path.join(runtimeDir, "server.py"), serverText, "utf8");
773
879
  await fsp.writeFile(path.join(runtimeDir, "skill_matcher.py"), matcherText, "utf8");
880
+ await fsp.writeFile(path.join(runtimeDir, "document_renderer.py"), rendererText, "utf8");
774
881
  await fsp.writeFile(path.join(runtimeDir, "requirements.txt"), requirementsText, "utf8");
882
+ if (useBundledRuntime && manifest.local_workspace === true) {
883
+ const { installWorkspaceRuntime } = await import("./workspace-runtime.js");
884
+ await installWorkspaceRuntime(BUNDLED_RUNTIME_DIR, runtimeDir);
885
+ }
775
886
  }
776
887
 
777
888
  async function loadRuntimeText(source, filePath, { useBundledRuntime = false } = {}) {
@@ -789,8 +900,7 @@ async function loadRuntimeText(source, filePath, { useBundledRuntime = false } =
789
900
  }
790
901
 
791
902
  function installPythonDeps(runtimeDir, vendorDir) {
792
- const python = findPython();
793
- if (!python) throw new Error("Python 3 is required but was not found on PATH.");
903
+ const python = requirePython();
794
904
  fs.mkdirSync(vendorDir, { recursive: true });
795
905
  const result = spawnSync(python, [
796
906
  "-m",
@@ -807,16 +917,6 @@ function installPythonDeps(runtimeDir, vendorDir) {
807
917
  }
808
918
  }
809
919
 
810
- function findPython() {
811
- for (const candidate of ["python3", "python"]) {
812
- const result = spawnSync(candidate, ["--version"], { encoding: "utf8" });
813
- if (result.status === 0 && /Python 3\./.test(`${result.stdout}${result.stderr}`)) {
814
- return candidate;
815
- }
816
- }
817
- return null;
818
- }
819
-
820
920
  async function resolveLicenseKey(options, vertical, installDir) {
821
921
  const envNames = ["TASKSAI_LICENSE_KEY", "LAWTASKSAI_LICENSE_KEY"];
822
922
  for (const name of envNames) {
@@ -988,9 +1088,9 @@ function getInstaller(manifest) {
988
1088
 
989
1089
  function parseSource(source, ref) {
990
1090
  if (source.startsWith("file://")) {
991
- return { kind: "file", root: source.slice("file://".length), ref, repoUrl: source };
1091
+ return { kind: "file", root: fileURLToPath(source), ref, repoUrl: source };
992
1092
  }
993
- if (source.startsWith("/") || source.startsWith(".")) {
1093
+ if (path.isAbsolute(source) || source.startsWith(".")) {
994
1094
  return { kind: "file", root: path.resolve(source), ref, repoUrl: source };
995
1095
  }
996
1096
  const rawMatch = source.match(/^https:\/\/raw\.githubusercontent\.com\/([^/]+)\/([^/]+)\/([^/]+)\/(.+)$/);
@@ -1194,18 +1294,19 @@ async function safeReportDoctorPassed({
1194
1294
  }
1195
1295
  }
1196
1296
 
1197
- async function verifyRuntimeHealth({ serverPath, vendorDir }) {
1198
- const python = findPython();
1199
- if (!python) throw new Error("Python 3 is unavailable");
1200
- const pythonPath = [...pythonVendorPaths(vendorDir), process.env.PYTHONPATH]
1297
+ async function verifyRuntimeHealth({ serverPath, vendorDir, isolated = false, workspace = false, python = requirePython() }) {
1298
+ const pythonPath = [...pythonVendorPaths(vendorDir), ...(isolated ? [path.dirname(serverPath)] : [process.env.PYTHONPATH])]
1201
1299
  .filter(Boolean)
1202
1300
  .join(path.delimiter);
1203
1301
  const check = [
1204
1302
  "import ast, pathlib, sys",
1303
+ "assert sys.version_info >= (3, 10), 'Python 3.10 or newer is required'",
1205
1304
  "ast.parse(pathlib.Path(sys.argv[1]).read_text(encoding='utf-8'), filename=sys.argv[1])",
1206
- "import httpx, dotenv, mcp, docx"
1305
+ "import httpx, dotenv, mcp, docx",
1306
+ ...(isolated ? ["[ast.parse(p.read_text(encoding='utf-8'), filename=str(p)) for p in pathlib.Path(sys.argv[1]).parent.rglob('*.py')]"] : []),
1307
+ ...(workspace ? ["import jsonschema, pypdf, xlsxwriter"] : [])
1207
1308
  ].join("; ");
1208
- const result = spawnSync(python, ["-c", check, serverPath], {
1309
+ const result = spawnSync(python, [...(isolated ? ["-S"] : []), "-c", check, serverPath], {
1209
1310
  encoding: "utf8",
1210
1311
  timeout: 10000,
1211
1312
  env: { ...process.env, PYTHONPATH: pythonPath }
@@ -1289,23 +1390,6 @@ function findExistingAncestor(targetPath) {
1289
1390
  return current;
1290
1391
  }
1291
1392
 
1292
- async function withLock(lockPath, callback) {
1293
- let handle;
1294
- try {
1295
- handle = await fsp.open(lockPath, "wx");
1296
- await handle.writeFile(String(process.pid));
1297
- return await callback();
1298
- } catch (error) {
1299
- if (error.code === "EEXIST") {
1300
- throw new Error(`Another TasksAI installer is already editing this config (${lockPath}).`);
1301
- }
1302
- throw error;
1303
- } finally {
1304
- if (handle) await handle.close();
1305
- await fsp.rm(lockPath, { force: true });
1306
- }
1307
- }
1308
-
1309
1393
  async function backupFile(filePath) {
1310
1394
  if (!fs.existsSync(filePath)) return null;
1311
1395
  const stamp = new Date().toISOString().replace(/[-:]/g, "").replace(/\..+/, "").replace("T", "-");
@@ -1380,6 +1464,7 @@ function redact(text) {
1380
1464
  export {
1381
1465
  INSTALLER_VERSION,
1382
1466
  authenticatedHeaders,
1467
+ buildMcpServerConfig,
1383
1468
  doctor,
1384
1469
  parseArgs,
1385
1470
  parseEnv,
@@ -1389,5 +1474,7 @@ export {
1389
1474
  safeReportDoctorPassed,
1390
1475
  verifyProductionSource,
1391
1476
  verifyRuntimeHealth,
1477
+ configuredPythonCommand,
1478
+ verifyUpdateClientPython,
1392
1479
  verifySource
1393
1480
  };