@tasksai/install 0.1.3 → 0.1.5

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 +3 -0
  2. package/package.json +1 -1
  3. package/src/index.js +189 -38
package/README.md CHANGED
@@ -28,6 +28,9 @@ The installer:
28
28
  - stores credentials outside MCP client config
29
29
  - configures supported MCP clients
30
30
  - checks required write access before changing local files
31
+ - prints customer-facing recovery instructions when an AI assistant is blocked
32
+ from writing local config files
33
+ - supports Claude Desktop, Cursor, Windsurf, and Codex
31
34
  - runs a local health check
32
35
 
33
36
  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.3",
3
+ "version": "0.1.5",
4
4
  "description": "Shared TasksAI MCP installer CLI",
5
5
  "type": "module",
6
6
  "bin": {
package/src/index.js CHANGED
@@ -9,7 +9,7 @@ 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.3";
12
+ const INSTALLER_VERSION = "0.1.5";
13
13
  const DEFAULT_SOURCES = {
14
14
  lawtasksai: "https://github.com/laudoluxDev/lawtasksai-mcp",
15
15
  farmer: "https://github.com/laudoluxDev/farmertasksai-mcp",
@@ -43,6 +43,14 @@ const CLIENTS = {
43
43
  configPath() {
44
44
  return path.join(os.homedir(), ".codeium", "windsurf", "mcp_config.json");
45
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
+ }
46
54
  }
47
55
  };
48
56
 
@@ -129,10 +137,10 @@ function parseArgs(argv) {
129
137
 
130
138
  function printUsage() {
131
139
  console.log(`Usage:
132
- tasksai-install <product-id> [install] [--source <repo-url>] [--ref <branch>] [--client claude-desktop|cursor|windsurf|all] [--auth browser|license-key] [--install-dir <path>]
133
- tasksai-install <product-id> doctor [--client claude-desktop|cursor|windsurf|all] [--install-dir <path>]
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>]
134
142
  tasksai-install <product-id> update [--install-dir <path>]
135
- tasksai-install <product-id> uninstall [--client claude-desktop|cursor|windsurf|all] [--install-dir <path>]
143
+ tasksai-install <product-id> uninstall [--client claude-desktop|cursor|windsurf|codex|all] [--install-dir <path>]
136
144
 
137
145
  Examples:
138
146
  tasksai-install lawtasksai --source https://github.com/laudoluxDev/lawtasksai-mcp
@@ -155,7 +163,7 @@ async function install(options, { updateOnly = false } = {}) {
155
163
  const runtimeDir = path.join(installDir, "runtime");
156
164
  const vendorDir = path.join(installDir, "python");
157
165
  const clients = updateOnly ? [] : resolveClients(options.client);
158
- await preflightWriteAccess({ operation: updateOnly ? "update" : "install", installDir, clients });
166
+ await preflightWriteAccess({ operation: updateOnly ? "update" : "install", installDir, clients, options, vertical, source });
159
167
 
160
168
  await fsp.mkdir(runtimeDir, { recursive: true });
161
169
  await fsp.mkdir(path.join(installDir, "logs"), { recursive: true });
@@ -220,10 +228,10 @@ async function doctor(options, { quietSuccess = false } = {}) {
220
228
  for (const client of clients) {
221
229
  const configPath = client.configPath();
222
230
  if (fs.existsSync(configPath)) {
223
- const config = await readJson(configPath);
224
- if (!config.mcpServers?.[vertical.product_id]) {
225
- problems.push(`${client.displayName} config does not contain mcpServers.${vertical.product_id}`);
226
- }
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}`);
227
235
  } else {
228
236
  problems.push(`${client.displayName} config not found at ${configPath}`);
229
237
  }
@@ -243,7 +251,7 @@ async function doctor(options, { quietSuccess = false } = {}) {
243
251
  async function uninstall(options) {
244
252
  const installDir = getInstallDir(options.productId, options);
245
253
  const clients = resolveClients(options.client);
246
- await preflightWriteAccess({ operation: "uninstall", installDir, clients });
254
+ await preflightWriteAccess({ operation: "uninstall", installDir, clients, options });
247
255
 
248
256
  for (const client of clients) {
249
257
  const configPath = client.configPath();
@@ -253,15 +261,25 @@ async function uninstall(options) {
253
261
  }
254
262
 
255
263
  await withLock(`${configPath}.lock`, async () => {
256
- const config = await readJson(configPath);
257
264
  const key = options.productId;
258
- if (!config.mcpServers?.[key]) {
259
- console.log(`${client.displayName} does not have a ${key} MCP entry.`);
260
- 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);
261
282
  }
262
- await backupFile(configPath);
263
- delete config.mcpServers[key];
264
- await atomicWriteJson(configPath, config);
265
283
  });
266
284
 
267
285
  console.log(`${options.productId} was removed from ${client.displayName}.`);
@@ -278,26 +296,106 @@ async function configureMcpClient({ client, vertical, installDir, runtimeDir, ve
278
296
  await fsp.mkdir(path.dirname(configPath), { recursive: true });
279
297
 
280
298
  await withLock(`${configPath}.lock`, async () => {
281
- const config = fs.existsSync(configPath) ? await readJson(configPath) : {};
282
- if (!config.mcpServers || typeof config.mcpServers !== "object") config.mcpServers = {};
283
-
299
+ const serverConfig = buildMcpServerConfig({ client, vertical, installDir, runtimeDir, vendorDir });
284
300
  await backupFile(configPath);
285
- config.mcpServers[vertical.product_id] = {
286
- command: "python3",
287
- args: [path.join(runtimeDir, "server.py")],
288
- env: {
289
- TASKSAI_PRODUCT_ID: vertical.product_id,
290
- TASKSAI_API_BASE: vertical.api_base_url,
291
- TASKSAI_CLIENT: client.id,
292
- PYTHONPATH: vendorDir,
293
- DOTENV_PATH: path.join(installDir, ".env")
294
- }
295
- };
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
+ }
296
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;
297
310
  await atomicWriteJson(configPath, config);
298
311
  });
299
312
  }
300
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
+
301
399
  function resolveClients(clientOption) {
302
400
  if (clientOption === "all") {
303
401
  return Object.values(CLIENTS);
@@ -309,7 +407,7 @@ function resolveClients(clientOption) {
309
407
  return [client];
310
408
  }
311
409
 
312
- async function preflightWriteAccess({ operation, installDir, clients }) {
410
+ async function preflightWriteAccess({ operation, installDir, clients, options, vertical, source }) {
313
411
  const checks = [
314
412
  {
315
413
  label: "TasksAI install directory",
@@ -342,13 +440,62 @@ async function preflightWriteAccess({ operation, installDir, clients }) {
342
440
 
343
441
  if (!failures.length) return;
344
442
 
345
- const detail = failures
443
+ throw new Error(formatPermissionRecovery({ operation, failures, options, vertical, source }));
444
+ }
445
+
446
+ function formatPermissionRecovery({ operation, failures, options, vertical, source }) {
447
+ const productName = vertical?.display_name || options.productId;
448
+ const clientNames = resolveClients(options.client).map((client) => client.displayName).join(", ");
449
+ const blockedPaths = failures
346
450
  .map((failure) => `- ${failure.label}: ${failure.targetPath} (${failure.reason})`)
347
451
  .join("\n");
348
- throw new Error(
349
- `TasksAI cannot ${operation} until it has write access to the required local paths:\n${detail}\n` +
350
- "Grant write access to these paths, or use --install-dir /path/to/product-dir for the TasksAI runtime."
351
- );
452
+ const installCommand = buildInstallCommand({ options, source });
453
+ const doctorCommand = buildDoctorCommand(options);
454
+ const installPage = vertical?.website_url ? `${vertical.website_url.replace(/\/$/, "")}/install.html` : null;
455
+ const supportPage = vertical?.website_url ? `${vertical.website_url.replace(/\/$/, "")}/support.html` : null;
456
+
457
+ return [
458
+ `Your AI assistant could not finish the ${productName} ${operation} because it cannot write to required local setup files.`,
459
+ "",
460
+ "This is a normal security restriction in some AI apps and sandboxed environments. No account data was changed.",
461
+ "",
462
+ "Blocked paths:",
463
+ blockedPaths,
464
+ "",
465
+ "To finish setup, run this official installer command directly in Terminal:",
466
+ installCommand,
467
+ "",
468
+ "Then restart your AI app and run this health check:",
469
+ doctorCommand,
470
+ "",
471
+ `Selected client: ${clientNames}`,
472
+ installPage ? `Install help: ${installPage}` : null,
473
+ supportPage ? `Support: ${supportPage}` : null,
474
+ "",
475
+ "If you still want the AI assistant to finish automatically, grant it write access to the blocked paths and rerun the same install prompt."
476
+ ].filter(Boolean).join("\n");
477
+ }
478
+
479
+ function buildInstallCommand({ options, source }) {
480
+ const parts = ["npx", "@tasksai/install", options.productId];
481
+ const sourceUrl = source?.repoUrl || options.source;
482
+ if (sourceUrl) parts.push("--source", sourceUrl);
483
+ if (options.client) parts.push("--client", options.client);
484
+ if (options.installDir) parts.push("--install-dir", resolveUserPath(options.installDir));
485
+ return parts.map(shellToken).join(" ");
486
+ }
487
+
488
+ function buildDoctorCommand(options) {
489
+ const parts = ["npx", "@tasksai/install", options.productId, "doctor"];
490
+ if (options.client) parts.push("--client", options.client);
491
+ if (options.installDir) parts.push("--install-dir", resolveUserPath(options.installDir));
492
+ return parts.map(shellToken).join(" ");
493
+ }
494
+
495
+ function shellToken(value) {
496
+ const text = String(value);
497
+ if (/^[A-Za-z0-9_./:@%+=,-]+$/.test(text)) return text;
498
+ return `'${text.replace(/'/g, "'\\''")}'`;
352
499
  }
353
500
 
354
501
  async function checkWritable({ label, targetPath, kind }) {
@@ -708,6 +855,10 @@ async function writeJson(filePath, value) {
708
855
  async function atomicWriteJson(filePath, value) {
709
856
  const text = `${JSON.stringify(value, null, 2)}\n`;
710
857
  JSON.parse(text);
858
+ await atomicWriteText(filePath, text);
859
+ }
860
+
861
+ async function atomicWriteText(filePath, text) {
711
862
  const tmpPath = `${filePath}.tmp-${process.pid}`;
712
863
  await fsp.writeFile(tmpPath, text, { encoding: "utf8", mode: 0o600 });
713
864
  await fsp.rename(tmpPath, filePath);
@@ -746,7 +897,7 @@ function formatError(error) {
746
897
 
747
898
  function redact(text) {
748
899
  return String(text)
749
- .replace(/\b[a-z]{2,4}_[A-Za-z0-9_-]{8,}\b/g, (match) => `${match.split("_")[0]}_[REDACTED]`)
900
+ .replace(/\b(?:lt|ft|pa|rt|tt|th|mt|ct|at|ch|cu|dt|ds|el|ep|fu|hr|in|ll|ms|mo|mu|nt|pt|pp|pl|pr|re|sl|ta|vt)_[A-Za-z0-9_-]{8,}\b/g, (match) => `${match.split("_")[0]}_[REDACTED]`)
750
901
  .replace(/Bearer\s+[A-Za-z0-9._-]+/gi, "Bearer [REDACTED]")
751
902
  .replace(/gh[opsu]_[A-Za-z0-9_]+/g, "gh_[REDACTED]");
752
903
  }