@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.
- package/README.md +3 -0
- package/package.json +1 -1
- 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
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.
|
|
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
|
|
224
|
-
|
|
225
|
-
|
|
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 (
|
|
259
|
-
|
|
260
|
-
|
|
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
|
|
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
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
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
|
-
|
|
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
|
-
|
|
349
|
-
|
|
350
|
-
|
|
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
|
|
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
|
}
|