@tasksai/install 0.1.38 → 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 +14 -3
  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 +217 -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";
@@ -21,6 +23,12 @@ const TRUSTED_PRODUCTION_SOURCES = new Map([
21
23
  ["farmer", { repo: OFFICIAL_WRAPPER_REPO, ref: "main", path: "verticals/farmer" }],
22
24
  ["realtor", { repo: OFFICIAL_WRAPPER_REPO, ref: "main", path: "verticals/realtor" }],
23
25
  ["teacher", { repo: OFFICIAL_WRAPPER_REPO, ref: "main", path: "verticals/teacher" }],
26
+ ["electrician", { repo: OFFICIAL_WRAPPER_REPO, ref: "main", path: "verticals/electrician" }],
27
+ ["hr", { repo: OFFICIAL_WRAPPER_REPO, ref: "main", path: "verticals/hr" }],
28
+ ["insurance", { repo: OFFICIAL_WRAPPER_REPO, ref: "main", path: "verticals/insurance" }],
29
+ ["mortgage", { repo: OFFICIAL_WRAPPER_REPO, ref: "main", path: "verticals/mortgage" }],
30
+ ["mortuary", { repo: OFFICIAL_WRAPPER_REPO, ref: "main", path: "verticals/mortuary" }],
31
+ ["restaurant", { repo: OFFICIAL_WRAPPER_REPO, ref: "main", path: "verticals/restaurant" }],
24
32
  ["contractor", { repo: "https://github.com/TasksAI-Official/contractortasksai-mcp", ref: "main", path: "", legacyManifestPathOptional: true }],
25
33
  ["therapist", { repo: "https://github.com/TasksAI-Official/therapisttasksai-mcp", ref: "main", path: "", legacyManifestPathOptional: true }],
26
34
  ["marketing", { repo: "https://github.com/TasksAI-Official/marketingtasksai-mcp", ref: "main", path: "", legacyManifestPathOptional: true }],
@@ -36,6 +44,12 @@ const DEFAULT_SOURCES = {
36
44
  farmer: "https://github.com/TasksAI-Official/tasksai-mcp-wrappers/tree/main/verticals/farmer",
37
45
  realtor: "https://github.com/TasksAI-Official/tasksai-mcp-wrappers/tree/main/verticals/realtor",
38
46
  teacher: "https://github.com/TasksAI-Official/tasksai-mcp-wrappers/tree/main/verticals/teacher",
47
+ electrician: "https://github.com/TasksAI-Official/tasksai-mcp-wrappers/tree/main/verticals/electrician",
48
+ hr: "https://github.com/TasksAI-Official/tasksai-mcp-wrappers/tree/main/verticals/hr",
49
+ insurance: "https://github.com/TasksAI-Official/tasksai-mcp-wrappers/tree/main/verticals/insurance",
50
+ mortgage: "https://github.com/TasksAI-Official/tasksai-mcp-wrappers/tree/main/verticals/mortgage",
51
+ mortuary: "https://github.com/TasksAI-Official/tasksai-mcp-wrappers/tree/main/verticals/mortuary",
52
+ restaurant: "https://github.com/TasksAI-Official/tasksai-mcp-wrappers/tree/main/verticals/restaurant",
39
53
  contractor: "https://github.com/TasksAI-Official/contractortasksai-mcp",
40
54
  therapist: "https://github.com/TasksAI-Official/therapisttasksai-mcp",
41
55
  marketing: "https://github.com/TasksAI-Official/marketingtasksai-mcp",
@@ -111,18 +125,31 @@ function isMainModule() {
111
125
  }
112
126
 
113
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
+ }
114
133
  const options = parseArgs(process.argv.slice(2));
115
134
 
116
135
  if (!options.productId) {
117
136
  printUsage();
118
137
  process.exit(1);
119
138
  }
139
+ setPythonInstallRoot(getInstallDir(options.productId, options));
120
140
 
121
141
  if (options.command === "doctor") {
122
142
  await doctor(options);
123
143
  return;
124
144
  }
125
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
+
126
153
  if (options.command === "update") {
127
154
  await install(options, { updateOnly: true });
128
155
  return;
@@ -175,7 +202,7 @@ function parseArgs(argv) {
175
202
 
176
203
  options.productId = positionals[0] || null;
177
204
  if (positionals[1]) options.command = positionals[1];
178
- if (!["install", "doctor", "update", "uninstall"].includes(options.command)) {
205
+ if (!["install", "doctor", "update", "uninstall", "recover"].includes(options.command)) {
179
206
  throw new Error(`Unsupported command: ${options.command}`);
180
207
  }
181
208
  if (!["browser", "license-key"].includes(options.auth)) {
@@ -193,6 +220,7 @@ function printUsage() {
193
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>]
194
221
  tasksai-install <product-id> doctor [--client claude-desktop|cursor|windsurf|codex|all] [--install-dir <path>]
195
222
  tasksai-install <product-id> update [--install-dir <path>]
223
+ tasksai-install <product-id> recover [--install-dir <path>]
196
224
  tasksai-install <product-id> uninstall [--client claude-desktop|cursor|windsurf|codex|all] [--install-dir <path>]
197
225
 
198
226
  Examples:
@@ -227,58 +255,79 @@ async function install(options, { updateOnly = false } = {}) {
227
255
  const clients = updateOnly ? [] : resolveClients(options.client);
228
256
  await preflightWriteAccess({ operation: updateOnly ? "update" : "install", installDir, clients, options, vertical, source });
229
257
 
230
- await fsp.mkdir(runtimeDir, { recursive: true });
231
- await fsp.mkdir(path.join(installDir, "logs"), { recursive: true });
232
- await logEvent(installDir, "install_start", {
233
- productId: options.productId,
234
- source: source.repoUrl,
235
- client: options.client,
236
- updateOnly
237
- });
238
-
239
- await writeJson(path.join(installDir, "agent-install.json"), manifest);
240
- await writeJson(path.join(installDir, "vertical.json"), vertical);
241
- await downloadRuntime(source, runtimeDir, manifest);
242
-
243
- const licenseKey = await resolveLicenseKey(options, vertical, installDir);
244
- const existingEnvPath = path.join(installDir, ".env");
245
- const existingEnv = fs.existsSync(existingEnvPath)
246
- ? parseEnv(await fsp.readFile(existingEnvPath, "utf8"))
247
- : {};
248
- const installId = firstValue(existingEnv.TASKSAI_INSTALL_ID, process.env.TASKSAI_INSTALL_ID) || randomUUID();
249
-
250
- if (!options.skipPythonDeps) {
251
- installPythonDeps(runtimeDir, vendorDir);
252
- }
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
+ });
253
268
 
254
- const envEntries = {
255
- TASKSAI_LICENSE_KEY: licenseKey,
256
- LAWTASKSAI_LICENSE_KEY: licenseKey,
257
- TASKSAI_PRODUCT_ID: vertical.product_id,
258
- TASKSAI_API_BASE: vertical.api_base_url,
259
- LAWTASKSAI_API_BASE: vertical.api_base_url,
260
- TASKSAI_INSTALL_ID: installId
261
- };
262
- await writeEnvFile(path.join(installDir, ".env"), envEntries);
263
- 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 });
264
312
 
265
- if (updateOnly) {
266
- await logEvent(installDir, "update_complete", { productId: options.productId });
267
- console.log(`${vertical.display_name} runtime updated at ${installDir}`);
268
- return;
269
- }
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
+ }
270
318
 
271
- for (const client of clients) {
272
- await configureMcpClient({ client, vertical, installDir, runtimeDir, vendorDir });
273
- }
319
+ for (const client of clients) {
320
+ await configureMcpClient({ client, vertical, installDir, runtimeDir, vendorDir });
321
+ }
274
322
 
275
- await doctor(options, { quietSuccess: true });
276
- await logEvent(installDir, "install_complete", {
277
- productId: options.productId,
278
- 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}"`);
279
330
  });
280
- console.log(`${vertical.display_name} is installed. Restart ${clients.map((client) => client.displayName).join(", ")} to see the tools.`);
281
- console.log(`After restart, ask: "${vertical.first_prompt}"`);
282
331
  }
283
332
 
284
333
  async function doctor(options, {
@@ -296,6 +345,7 @@ async function doctor(options, {
296
345
  const clients = clientsOverride || resolveClients(options.client);
297
346
 
298
347
  const problems = [];
348
+ const configuredCommands = new Map();
299
349
  if (!fs.existsSync(verticalPath)) problems.push(`Missing ${verticalPath}`);
300
350
  if (!fs.existsSync(envPath)) problems.push(`Missing ${envPath}`);
301
351
  if (!fs.existsSync(serverPath)) problems.push(`Missing ${serverPath}`);
@@ -310,6 +360,11 @@ async function doctor(options, {
310
360
  ? await codexConfigHasServer(configPath, vertical.product_id)
311
361
  : await jsonConfigHasServer(configPath, vertical.product_id);
312
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
+ }
313
368
  } else {
314
369
  problems.push(`${client.displayName} config not found at ${configPath}`);
315
370
  }
@@ -321,6 +376,13 @@ async function doctor(options, {
321
376
  } catch (error) {
322
377
  problems.push(`Runtime health check failed (${healthErrorSummary(error)})`);
323
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
+ }
324
386
  }
325
387
 
326
388
  const productId = String(vertical.product_id || options.productId || "").trim().toLowerCase();
@@ -413,42 +475,45 @@ async function uninstall(options) {
413
475
  const installDir = getInstallDir(options.productId, options);
414
476
  const clients = resolveClients(options.client);
415
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 () => {
416
480
 
417
- for (const client of clients) {
418
- const configPath = client.configPath();
419
- if (!fs.existsSync(configPath)) {
420
- console.log(`${client.displayName} config not found; nothing to remove.`);
421
- continue;
422
- }
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
+ }
423
487
 
424
- await withLock(`${configPath}.lock`, async () => {
425
- const keys = mcpServerKeysForProduct(options.productId);
426
- if (client.configFormat === "toml") {
427
- const text = await fsp.readFile(configPath, "utf8");
428
- if (!tomlHasAnyMcpServer(text, keys)) {
429
- console.log(`${client.displayName} does not have a ${options.productId} MCP entry.`);
430
- return;
431
- }
432
- await backupFile(configPath);
433
- await atomicWriteText(configPath, removeTomlSections(text, tomlSectionNamesForProduct(options.productId)));
434
- } else {
435
- const config = await readJson(configPath);
436
- if (!keys.some((key) => config.mcpServers?.[key])) {
437
- console.log(`${client.displayName} does not have a ${options.productId} MCP entry.`);
438
- 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);
439
507
  }
440
- await backupFile(configPath);
441
- for (const key of keys) delete config.mcpServers[key];
442
- await atomicWriteJson(configPath, config);
443
- }
444
- });
508
+ });
445
509
 
446
- console.log(`${options.productId} was removed from ${client.displayName}.`);
447
- }
510
+ console.log(`${options.productId} was removed from ${client.displayName}.`);
511
+ }
448
512
 
449
- await logEvent(installDir, "uninstall_complete", {
450
- productId: options.productId,
451
- clients: clients.map((client) => client.id)
513
+ await logEvent(installDir, "uninstall_complete", {
514
+ productId: options.productId,
515
+ clients: clients.map((client) => client.id)
516
+ });
452
517
  });
453
518
  }
454
519
 
@@ -475,7 +540,7 @@ async function configureMcpClient({ client, vertical, installDir, runtimeDir, ve
475
540
 
476
541
  function buildMcpServerConfig({ client, vertical, installDir, runtimeDir, vendorDir }) {
477
542
  return {
478
- command: pythonCommandForPlatform(),
543
+ command: requirePython(),
479
544
  args: [path.join(runtimeDir, "server.py")],
480
545
  env: {
481
546
  TASKSAI_PRODUCT_ID: vertical.product_id,
@@ -501,6 +566,57 @@ function pythonVendorPaths(vendorDir, platform = process.platform) {
501
566
  ];
502
567
  }
503
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
+
504
620
  async function jsonConfigHasServer(configPath, productId) {
505
621
  const config = await readJson(configPath);
506
622
  return Boolean(config.mcpServers?.[productId]);
@@ -756,10 +872,17 @@ async function downloadRuntime(source, runtimeDir, manifest = {}) {
756
872
  const useBundledRuntime = manifest.runtime?.source === "shared_installer";
757
873
  const serverText = await loadRuntimeText(source, "server.py", { useBundledRuntime });
758
874
  const matcherText = await loadRuntimeText(source, "skill_matcher.py", { useBundledRuntime });
875
+ const rendererText = await loadRuntimeText(source, "document_renderer.py", { useBundledRuntime });
759
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"));
760
878
  await fsp.writeFile(path.join(runtimeDir, "server.py"), serverText, "utf8");
761
879
  await fsp.writeFile(path.join(runtimeDir, "skill_matcher.py"), matcherText, "utf8");
880
+ await fsp.writeFile(path.join(runtimeDir, "document_renderer.py"), rendererText, "utf8");
762
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
+ }
763
886
  }
764
887
 
765
888
  async function loadRuntimeText(source, filePath, { useBundledRuntime = false } = {}) {
@@ -777,8 +900,7 @@ async function loadRuntimeText(source, filePath, { useBundledRuntime = false } =
777
900
  }
778
901
 
779
902
  function installPythonDeps(runtimeDir, vendorDir) {
780
- const python = findPython();
781
- if (!python) throw new Error("Python 3 is required but was not found on PATH.");
903
+ const python = requirePython();
782
904
  fs.mkdirSync(vendorDir, { recursive: true });
783
905
  const result = spawnSync(python, [
784
906
  "-m",
@@ -795,16 +917,6 @@ function installPythonDeps(runtimeDir, vendorDir) {
795
917
  }
796
918
  }
797
919
 
798
- function findPython() {
799
- for (const candidate of ["python3", "python"]) {
800
- const result = spawnSync(candidate, ["--version"], { encoding: "utf8" });
801
- if (result.status === 0 && /Python 3\./.test(`${result.stdout}${result.stderr}`)) {
802
- return candidate;
803
- }
804
- }
805
- return null;
806
- }
807
-
808
920
  async function resolveLicenseKey(options, vertical, installDir) {
809
921
  const envNames = ["TASKSAI_LICENSE_KEY", "LAWTASKSAI_LICENSE_KEY"];
810
922
  for (const name of envNames) {
@@ -976,9 +1088,9 @@ function getInstaller(manifest) {
976
1088
 
977
1089
  function parseSource(source, ref) {
978
1090
  if (source.startsWith("file://")) {
979
- return { kind: "file", root: source.slice("file://".length), ref, repoUrl: source };
1091
+ return { kind: "file", root: fileURLToPath(source), ref, repoUrl: source };
980
1092
  }
981
- if (source.startsWith("/") || source.startsWith(".")) {
1093
+ if (path.isAbsolute(source) || source.startsWith(".")) {
982
1094
  return { kind: "file", root: path.resolve(source), ref, repoUrl: source };
983
1095
  }
984
1096
  const rawMatch = source.match(/^https:\/\/raw\.githubusercontent\.com\/([^/]+)\/([^/]+)\/([^/]+)\/(.+)$/);
@@ -1182,18 +1294,19 @@ async function safeReportDoctorPassed({
1182
1294
  }
1183
1295
  }
1184
1296
 
1185
- async function verifyRuntimeHealth({ serverPath, vendorDir }) {
1186
- const python = findPython();
1187
- if (!python) throw new Error("Python 3 is unavailable");
1188
- 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])]
1189
1299
  .filter(Boolean)
1190
1300
  .join(path.delimiter);
1191
1301
  const check = [
1192
1302
  "import ast, pathlib, sys",
1303
+ "assert sys.version_info >= (3, 10), 'Python 3.10 or newer is required'",
1193
1304
  "ast.parse(pathlib.Path(sys.argv[1]).read_text(encoding='utf-8'), filename=sys.argv[1])",
1194
- "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"] : [])
1195
1308
  ].join("; ");
1196
- const result = spawnSync(python, ["-c", check, serverPath], {
1309
+ const result = spawnSync(python, [...(isolated ? ["-S"] : []), "-c", check, serverPath], {
1197
1310
  encoding: "utf8",
1198
1311
  timeout: 10000,
1199
1312
  env: { ...process.env, PYTHONPATH: pythonPath }
@@ -1277,23 +1390,6 @@ function findExistingAncestor(targetPath) {
1277
1390
  return current;
1278
1391
  }
1279
1392
 
1280
- async function withLock(lockPath, callback) {
1281
- let handle;
1282
- try {
1283
- handle = await fsp.open(lockPath, "wx");
1284
- await handle.writeFile(String(process.pid));
1285
- return await callback();
1286
- } catch (error) {
1287
- if (error.code === "EEXIST") {
1288
- throw new Error(`Another TasksAI installer is already editing this config (${lockPath}).`);
1289
- }
1290
- throw error;
1291
- } finally {
1292
- if (handle) await handle.close();
1293
- await fsp.rm(lockPath, { force: true });
1294
- }
1295
- }
1296
-
1297
1393
  async function backupFile(filePath) {
1298
1394
  if (!fs.existsSync(filePath)) return null;
1299
1395
  const stamp = new Date().toISOString().replace(/[-:]/g, "").replace(/\..+/, "").replace("T", "-");
@@ -1368,6 +1464,7 @@ function redact(text) {
1368
1464
  export {
1369
1465
  INSTALLER_VERSION,
1370
1466
  authenticatedHeaders,
1467
+ buildMcpServerConfig,
1371
1468
  doctor,
1372
1469
  parseArgs,
1373
1470
  parseEnv,
@@ -1377,5 +1474,7 @@ export {
1377
1474
  safeReportDoctorPassed,
1378
1475
  verifyProductionSource,
1379
1476
  verifyRuntimeHealth,
1477
+ configuredPythonCommand,
1478
+ verifyUpdateClientPython,
1380
1479
  verifySource
1381
1480
  };