@tasksai/install 0.1.2 → 0.1.4

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 (3) hide show
  1. package/README.md +14 -0
  2. package/package.json +1 -1
  3. package/src/index.js +253 -38
package/README.md CHANGED
@@ -8,6 +8,18 @@ Users normally run this through a product-specific GitHub manifest, for example:
8
8
  npx @tasksai/install lawtasksai --source https://github.com/laudoluxDev/lawtasksai-mcp
9
9
  ```
10
10
 
11
+ In restricted agent environments, grant write access to the default TasksAI
12
+ application data folder and the selected MCP client config. If the runtime must
13
+ be installed elsewhere, pass an exact product install directory:
14
+
15
+ ```bash
16
+ npx @tasksai/install farmer \
17
+ --source https://github.com/laudoluxDev/farmertasksai-mcp \
18
+ --install-dir /tmp/tasksai/farmer
19
+ ```
20
+
21
+ `TASKSAI_INSTALL_DIR=/tmp/tasksai/farmer` is equivalent to `--install-dir`.
22
+
11
23
  The installer:
12
24
 
13
25
  - verifies the official vertical manifest
@@ -15,6 +27,8 @@ The installer:
15
27
  - connects the user's TasksAI account through browser approval when available
16
28
  - stores credentials outside MCP client config
17
29
  - configures supported MCP clients
30
+ - checks required write access before changing local files
31
+ - supports Claude Desktop, Cursor, Windsurf, and Codex
18
32
  - runs a local health check
19
33
 
20
34
  TasksAI servers handle authentication, credits, catalog/search metadata, and
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tasksai/install",
3
- "version": "0.1.2",
3
+ "version": "0.1.4",
4
4
  "description": "Shared TasksAI MCP installer CLI",
5
5
  "type": "module",
6
6
  "bin": {
package/src/index.js CHANGED
@@ -9,10 +9,11 @@ import readline from "node:readline/promises";
9
9
  import { spawnSync } from "node:child_process";
10
10
  import { stdin as input, stdout as output } from "node:process";
11
11
 
12
- const INSTALLER_VERSION = "0.1.2";
12
+ const INSTALLER_VERSION = "0.1.4";
13
13
  const DEFAULT_SOURCES = {
14
14
  lawtasksai: "https://github.com/laudoluxDev/lawtasksai-mcp",
15
- farmer: "https://github.com/laudoluxDev/farmertasksai-mcp"
15
+ farmer: "https://github.com/laudoluxDev/farmertasksai-mcp",
16
+ priorauthai: "https://github.com/laudoluxDev/priorauthai-mcp"
16
17
  };
17
18
 
18
19
  const CLIENTS = {
@@ -42,6 +43,14 @@ const CLIENTS = {
42
43
  configPath() {
43
44
  return path.join(os.homedir(), ".codeium", "windsurf", "mcp_config.json");
44
45
  }
46
+ },
47
+ codex: {
48
+ id: "codex",
49
+ displayName: "Codex",
50
+ configFormat: "toml",
51
+ configPath() {
52
+ return path.join(os.homedir(), ".codex", "config.toml");
53
+ }
45
54
  }
46
55
  };
47
56
 
@@ -84,6 +93,7 @@ function parseArgs(argv) {
84
93
  ref: "main",
85
94
  client: "claude-desktop",
86
95
  auth: "browser",
96
+ installDir: process.env.TASKSAI_INSTALL_DIR || null,
87
97
  noBrowser: false,
88
98
  yes: false,
89
99
  skipPythonDeps: false
@@ -96,6 +106,7 @@ function parseArgs(argv) {
96
106
  else if (arg === "--ref") options.ref = argv[++i];
97
107
  else if (arg === "--client") options.client = argv[++i];
98
108
  else if (arg === "--auth") options.auth = argv[++i];
109
+ else if (arg === "--install-dir") options.installDir = argv[++i];
99
110
  else if (arg === "--no-browser") options.noBrowser = true;
100
111
  else if (arg === "--yes" || arg === "-y") options.yes = true;
101
112
  else if (arg === "--skip-python-deps") options.skipPythonDeps = true;
@@ -118,7 +129,7 @@ function parseArgs(argv) {
118
129
  throw new Error(`Unsupported auth mode: ${options.auth}. Supported modes: browser, license-key`);
119
130
  }
120
131
  options.source ||= DEFAULT_SOURCES[options.productId];
121
- if (!options.source) {
132
+ if (!options.source && ["install", "update"].includes(options.command)) {
122
133
  throw new Error(`No default source is known for product: ${options.productId}`);
123
134
  }
124
135
  return options;
@@ -126,15 +137,19 @@ function parseArgs(argv) {
126
137
 
127
138
  function printUsage() {
128
139
  console.log(`Usage:
129
- tasksai-install <product-id> [install] [--source <repo-url>] [--ref <branch>] [--client claude-desktop|cursor|windsurf|all] [--auth browser|license-key]
130
- tasksai-install <product-id> doctor [--client claude-desktop|cursor|windsurf|all]
131
- tasksai-install <product-id> update
132
- tasksai-install <product-id> uninstall [--client claude-desktop|cursor|windsurf|all]
140
+ tasksai-install <product-id> [install] [--source <repo-url>] [--ref <branch>] [--client claude-desktop|cursor|windsurf|codex|all] [--auth browser|license-key] [--install-dir <path>]
141
+ tasksai-install <product-id> doctor [--client claude-desktop|cursor|windsurf|codex|all] [--install-dir <path>]
142
+ tasksai-install <product-id> update [--install-dir <path>]
143
+ tasksai-install <product-id> uninstall [--client claude-desktop|cursor|windsurf|codex|all] [--install-dir <path>]
133
144
 
134
145
  Examples:
135
146
  tasksai-install lawtasksai --source https://github.com/laudoluxDev/lawtasksai-mcp
136
147
  tasksai-install farmer --source https://github.com/laudoluxDev/farmertasksai-mcp
148
+ tasksai-install farmer --install-dir /tmp/tasksai/farmer
137
149
  tasksai-install lawtasksai doctor
150
+
151
+ Environment:
152
+ TASKSAI_INSTALL_DIR may be used instead of --install-dir.
138
153
  `);
139
154
  }
140
155
 
@@ -144,9 +159,12 @@ async function install(options, { updateOnly = false } = {}) {
144
159
  const vertical = await loadJson(source, "vertical.json");
145
160
  verifySource({ options, source, manifest, vertical });
146
161
 
147
- const installDir = getInstallDir(options.productId);
162
+ const installDir = getInstallDir(options.productId, options);
148
163
  const runtimeDir = path.join(installDir, "runtime");
149
164
  const vendorDir = path.join(installDir, "python");
165
+ const clients = updateOnly ? [] : resolveClients(options.client);
166
+ await preflightWriteAccess({ operation: updateOnly ? "update" : "install", installDir, clients });
167
+
150
168
  await fsp.mkdir(runtimeDir, { recursive: true });
151
169
  await fsp.mkdir(path.join(installDir, "logs"), { recursive: true });
152
170
  await logEvent(installDir, "install_start", {
@@ -181,7 +199,6 @@ async function install(options, { updateOnly = false } = {}) {
181
199
  return;
182
200
  }
183
201
 
184
- const clients = resolveClients(options.client);
185
202
  for (const client of clients) {
186
203
  await configureMcpClient({ client, vertical, installDir, runtimeDir, vendorDir });
187
204
  }
@@ -196,7 +213,7 @@ async function install(options, { updateOnly = false } = {}) {
196
213
  }
197
214
 
198
215
  async function doctor(options, { quietSuccess = false } = {}) {
199
- const installDir = getInstallDir(options.productId);
216
+ const installDir = getInstallDir(options.productId, options);
200
217
  const verticalPath = path.join(installDir, "vertical.json");
201
218
  const envPath = path.join(installDir, ".env");
202
219
  const serverPath = path.join(installDir, "runtime", "server.py");
@@ -211,10 +228,10 @@ async function doctor(options, { quietSuccess = false } = {}) {
211
228
  for (const client of clients) {
212
229
  const configPath = client.configPath();
213
230
  if (fs.existsSync(configPath)) {
214
- const config = await readJson(configPath);
215
- if (!config.mcpServers?.[vertical.product_id]) {
216
- problems.push(`${client.displayName} config does not contain mcpServers.${vertical.product_id}`);
217
- }
231
+ const hasServer = client.configFormat === "toml"
232
+ ? await codexConfigHasServer(configPath, vertical.product_id)
233
+ : await jsonConfigHasServer(configPath, vertical.product_id);
234
+ if (!hasServer) problems.push(`${client.displayName} config does not contain mcpServers.${vertical.product_id}`);
218
235
  } else {
219
236
  problems.push(`${client.displayName} config not found at ${configPath}`);
220
237
  }
@@ -224,7 +241,7 @@ async function doctor(options, { quietSuccess = false } = {}) {
224
241
  throw new Error(`TasksAI doctor found issues:\n- ${problems.join("\n- ")}`);
225
242
  }
226
243
 
227
- await logEvent(installDir, "doctor_passed", {
244
+ await safeLogEvent(installDir, "doctor_passed", {
228
245
  productId: options.productId,
229
246
  clients: clients.map((client) => client.id)
230
247
  });
@@ -232,8 +249,9 @@ async function doctor(options, { quietSuccess = false } = {}) {
232
249
  }
233
250
 
234
251
  async function uninstall(options) {
235
- const installDir = getInstallDir(options.productId);
252
+ const installDir = getInstallDir(options.productId, options);
236
253
  const clients = resolveClients(options.client);
254
+ await preflightWriteAccess({ operation: "uninstall", installDir, clients });
237
255
 
238
256
  for (const client of clients) {
239
257
  const configPath = client.configPath();
@@ -243,15 +261,25 @@ async function uninstall(options) {
243
261
  }
244
262
 
245
263
  await withLock(`${configPath}.lock`, async () => {
246
- const config = await readJson(configPath);
247
264
  const key = options.productId;
248
- if (!config.mcpServers?.[key]) {
249
- console.log(`${client.displayName} does not have a ${key} MCP entry.`);
250
- return;
265
+ if (client.configFormat === "toml") {
266
+ const text = await fsp.readFile(configPath, "utf8");
267
+ if (!tomlHasMcpServer(text, key)) {
268
+ console.log(`${client.displayName} does not have a ${key} MCP entry.`);
269
+ return;
270
+ }
271
+ await backupFile(configPath);
272
+ await atomicWriteText(configPath, removeTomlSections(text, [`mcp_servers.${key}`, `mcp_servers.${key}.env`]));
273
+ } else {
274
+ const config = await readJson(configPath);
275
+ if (!config.mcpServers?.[key]) {
276
+ console.log(`${client.displayName} does not have a ${key} MCP entry.`);
277
+ return;
278
+ }
279
+ await backupFile(configPath);
280
+ delete config.mcpServers[key];
281
+ await atomicWriteJson(configPath, config);
251
282
  }
252
- await backupFile(configPath);
253
- delete config.mcpServers[key];
254
- await atomicWriteJson(configPath, config);
255
283
  });
256
284
 
257
285
  console.log(`${options.productId} was removed from ${client.displayName}.`);
@@ -268,26 +296,106 @@ async function configureMcpClient({ client, vertical, installDir, runtimeDir, ve
268
296
  await fsp.mkdir(path.dirname(configPath), { recursive: true });
269
297
 
270
298
  await withLock(`${configPath}.lock`, async () => {
271
- const config = fs.existsSync(configPath) ? await readJson(configPath) : {};
272
- if (!config.mcpServers || typeof config.mcpServers !== "object") config.mcpServers = {};
273
-
299
+ const serverConfig = buildMcpServerConfig({ client, vertical, installDir, runtimeDir, vendorDir });
274
300
  await backupFile(configPath);
275
- config.mcpServers[vertical.product_id] = {
276
- command: "python3",
277
- args: [path.join(runtimeDir, "server.py")],
278
- env: {
279
- TASKSAI_PRODUCT_ID: vertical.product_id,
280
- TASKSAI_API_BASE: vertical.api_base_url,
281
- TASKSAI_CLIENT: client.id,
282
- PYTHONPATH: vendorDir,
283
- DOTENV_PATH: path.join(installDir, ".env")
284
- }
285
- };
301
+ if (client.configFormat === "toml") {
302
+ const text = fs.existsSync(configPath) ? await fsp.readFile(configPath, "utf8") : "";
303
+ await atomicWriteText(configPath, upsertCodexMcpServer(text, vertical.product_id, serverConfig));
304
+ return;
305
+ }
286
306
 
307
+ const config = fs.existsSync(configPath) ? await readJson(configPath) : {};
308
+ if (!config.mcpServers || typeof config.mcpServers !== "object") config.mcpServers = {};
309
+ config.mcpServers[vertical.product_id] = serverConfig;
287
310
  await atomicWriteJson(configPath, config);
288
311
  });
289
312
  }
290
313
 
314
+ function buildMcpServerConfig({ client, vertical, installDir, runtimeDir, vendorDir }) {
315
+ return {
316
+ command: "python3",
317
+ args: [path.join(runtimeDir, "server.py")],
318
+ env: {
319
+ TASKSAI_PRODUCT_ID: vertical.product_id,
320
+ TASKSAI_API_BASE: vertical.api_base_url,
321
+ TASKSAI_CLIENT: client.id,
322
+ PYTHONPATH: vendorDir,
323
+ DOTENV_PATH: path.join(installDir, ".env")
324
+ }
325
+ };
326
+ }
327
+
328
+ async function jsonConfigHasServer(configPath, productId) {
329
+ const config = await readJson(configPath);
330
+ return Boolean(config.mcpServers?.[productId]);
331
+ }
332
+
333
+ async function codexConfigHasServer(configPath, productId) {
334
+ const text = await fsp.readFile(configPath, "utf8");
335
+ return tomlHasMcpServer(text, productId);
336
+ }
337
+
338
+ function tomlHasMcpServer(text, productId) {
339
+ return getTomlSectionNames(text).includes(`mcp_servers.${productId}`);
340
+ }
341
+
342
+ function getTomlSectionNames(text) {
343
+ return text
344
+ .split(/\r?\n/)
345
+ .map((line) => {
346
+ const match = line.match(/^\s*\[([^\]]+)\]\s*(?:#.*)?$/);
347
+ return match?.[1] || null;
348
+ })
349
+ .filter(Boolean);
350
+ }
351
+
352
+ function upsertCodexMcpServer(text, productId, serverConfig) {
353
+ const withoutServer = removeTomlSections(text, [`mcp_servers.${productId}`, `mcp_servers.${productId}.env`]).trimEnd();
354
+ const block = formatCodexMcpServer(productId, serverConfig);
355
+ return `${withoutServer ? `${withoutServer}\n\n` : ""}${block}\n`;
356
+ }
357
+
358
+ function removeTomlSections(text, sectionNames) {
359
+ const sections = new Set(sectionNames);
360
+ const lines = text.split(/\r?\n/);
361
+ const kept = [];
362
+ let skipping = false;
363
+
364
+ for (const line of lines) {
365
+ const match = line.match(/^\s*\[([^\]]+)\]\s*(?:#.*)?$/);
366
+ if (match) {
367
+ skipping = sections.has(match[1]);
368
+ }
369
+ if (!skipping) kept.push(line);
370
+ }
371
+
372
+ return kept.join("\n").replace(/\n{3,}/g, "\n\n");
373
+ }
374
+
375
+ function formatCodexMcpServer(productId, serverConfig) {
376
+ const lines = [
377
+ `[mcp_servers.${productId}]`,
378
+ `command = ${tomlString(serverConfig.command)}`,
379
+ `args = ${tomlArray(serverConfig.args)}`,
380
+ "",
381
+ `[mcp_servers.${productId}.env]`
382
+ ];
383
+
384
+ for (const [key, value] of Object.entries(serverConfig.env || {})) {
385
+ lines.push(`${key} = ${tomlString(value)}`);
386
+ }
387
+
388
+ return lines.join("\n");
389
+ }
390
+
391
+ function tomlArray(values) {
392
+ return `[${values.map((value) => tomlString(value)).join(", ")}]`;
393
+ }
394
+
395
+ function tomlString(value) {
396
+ return `"${String(value).replace(/\\/g, "\\\\").replace(/"/g, '\\"')}"`;
397
+ }
398
+
291
399
  function resolveClients(clientOption) {
292
400
  if (clientOption === "all") {
293
401
  return Object.values(CLIENTS);
@@ -299,6 +407,79 @@ function resolveClients(clientOption) {
299
407
  return [client];
300
408
  }
301
409
 
410
+ async function preflightWriteAccess({ operation, installDir, clients }) {
411
+ const checks = [
412
+ {
413
+ label: "TasksAI install directory",
414
+ targetPath: installDir,
415
+ kind: "directory"
416
+ }
417
+ ];
418
+
419
+ for (const client of clients) {
420
+ const configPath = client.configPath();
421
+ checks.push({
422
+ label: `${client.displayName} config directory`,
423
+ targetPath: path.dirname(configPath),
424
+ kind: "directory"
425
+ });
426
+ if (fs.existsSync(configPath)) {
427
+ checks.push({
428
+ label: `${client.displayName} config file`,
429
+ targetPath: configPath,
430
+ kind: "file"
431
+ });
432
+ }
433
+ }
434
+
435
+ const failures = [];
436
+ for (const check of checks) {
437
+ const problem = await checkWritable(check);
438
+ if (problem) failures.push(problem);
439
+ }
440
+
441
+ if (!failures.length) return;
442
+
443
+ const detail = failures
444
+ .map((failure) => `- ${failure.label}: ${failure.targetPath} (${failure.reason})`)
445
+ .join("\n");
446
+ throw new Error(
447
+ `TasksAI cannot ${operation} until it has write access to the required local paths:\n${detail}\n` +
448
+ "Grant write access to these paths, or use --install-dir /path/to/product-dir for the TasksAI runtime."
449
+ );
450
+ }
451
+
452
+ async function checkWritable({ label, targetPath, kind }) {
453
+ const resolvedPath = resolveUserPath(targetPath);
454
+ try {
455
+ if (fs.existsSync(resolvedPath)) {
456
+ const stat = await fsp.stat(resolvedPath);
457
+ if (kind === "directory" && !stat.isDirectory()) {
458
+ return { label, targetPath: resolvedPath, reason: "expected a directory" };
459
+ }
460
+ if (kind === "file" && !stat.isFile()) {
461
+ return { label, targetPath: resolvedPath, reason: "expected a file" };
462
+ }
463
+ await fsp.access(resolvedPath, fs.constants.W_OK);
464
+ if (kind === "file") {
465
+ await fsp.access(path.dirname(resolvedPath), fs.constants.W_OK);
466
+ }
467
+ return null;
468
+ }
469
+
470
+ const basePath = kind === "file" ? path.dirname(resolvedPath) : resolvedPath;
471
+ const ancestor = findExistingAncestor(basePath);
472
+ await fsp.access(ancestor, fs.constants.W_OK);
473
+ return null;
474
+ } catch (error) {
475
+ return {
476
+ label,
477
+ targetPath: resolvedPath,
478
+ reason: error.code === "EACCES" || error.code === "EPERM" ? "permission denied" : error.message
479
+ };
480
+ }
481
+ }
482
+
302
483
  async function downloadRuntime(source, runtimeDir) {
303
484
  const serverText = await loadText(source, "server.py");
304
485
  const requirementsText = await loadText(source, "requirements.txt");
@@ -552,7 +733,10 @@ function delay(ms) {
552
733
  });
553
734
  }
554
735
 
555
- function getInstallDir(productId) {
736
+ function getInstallDir(productId, options = {}) {
737
+ if (options.installDir) {
738
+ return resolveUserPath(options.installDir);
739
+ }
556
740
  if (process.platform === "darwin") {
557
741
  return path.join(os.homedir(), "Library", "Application Support", "TasksAI", productId);
558
742
  }
@@ -562,6 +746,25 @@ function getInstallDir(productId) {
562
746
  return path.join(os.homedir(), ".local", "share", "TasksAI", productId);
563
747
  }
564
748
 
749
+ function resolveUserPath(value) {
750
+ if (!value) return value;
751
+ if (value === "~") return os.homedir();
752
+ if (value.startsWith("~/") || value.startsWith("~\\")) {
753
+ return path.join(os.homedir(), value.slice(2));
754
+ }
755
+ return path.resolve(value);
756
+ }
757
+
758
+ function findExistingAncestor(targetPath) {
759
+ let current = resolveUserPath(targetPath);
760
+ while (!fs.existsSync(current)) {
761
+ const parent = path.dirname(current);
762
+ if (parent === current) return current;
763
+ current = parent;
764
+ }
765
+ return current;
766
+ }
767
+
565
768
  async function withLock(lockPath, callback) {
566
769
  let handle;
567
770
  try {
@@ -603,6 +806,10 @@ async function writeJson(filePath, value) {
603
806
  async function atomicWriteJson(filePath, value) {
604
807
  const text = `${JSON.stringify(value, null, 2)}\n`;
605
808
  JSON.parse(text);
809
+ await atomicWriteText(filePath, text);
810
+ }
811
+
812
+ async function atomicWriteText(filePath, text) {
606
813
  const tmpPath = `${filePath}.tmp-${process.pid}`;
607
814
  await fsp.writeFile(tmpPath, text, { encoding: "utf8", mode: 0o600 });
608
815
  await fsp.rename(tmpPath, filePath);
@@ -626,6 +833,14 @@ async function logEvent(installDir, event, details = {}) {
626
833
  await fsp.appendFile(path.join(logDir, "installer.log"), line, "utf8");
627
834
  }
628
835
 
836
+ async function safeLogEvent(installDir, event, details = {}) {
837
+ try {
838
+ await logEvent(installDir, event, details);
839
+ } catch {
840
+ // Health checks should report installation health, not fail on log writes.
841
+ }
842
+ }
843
+
629
844
  function formatError(error) {
630
845
  const message = error?.message || String(error);
631
846
  return `TasksAI installer failed: ${redact(message)}`;