@tasksai/install 0.1.7 → 0.1.9

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 CHANGED
@@ -5,7 +5,7 @@ Official installer CLI for TasksAI MCP verticals.
5
5
  Users normally run this through a product-specific GitHub manifest, for example:
6
6
 
7
7
  ```bash
8
- npx @tasksai/install lawtasksai --source https://github.com/laudoluxDev/lawtasksai-mcp
8
+ npm exec --package=@tasksai/install --call 'tasksai-install lawtasksai --source https://github.com/laudoluxDev/lawtasksai-mcp'
9
9
  ```
10
10
 
11
11
  In restricted agent environments, grant write access to the default TasksAI
@@ -13,9 +13,7 @@ application data folder and the selected MCP client config. If the runtime must
13
13
  be installed elsewhere, pass an exact product install directory:
14
14
 
15
15
  ```bash
16
- npx @tasksai/install farmer \
17
- --source https://github.com/laudoluxDev/farmertasksai-mcp \
18
- --install-dir /tmp/tasksai/farmer
16
+ npm exec --package=@tasksai/install --call 'tasksai-install farmer --source https://github.com/laudoluxDev/farmertasksai-mcp --install-dir /tmp/tasksai/farmer'
19
17
  ```
20
18
 
21
19
  `TASKSAI_INSTALL_DIR=/tmp/tasksai/farmer` is equivalent to `--install-dir`.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tasksai/install",
3
- "version": "0.1.7",
3
+ "version": "0.1.9",
4
4
  "description": "Shared TasksAI MCP installer CLI",
5
5
  "type": "module",
6
6
  "bin": {
@@ -8,6 +8,7 @@
8
8
  },
9
9
  "files": [
10
10
  "src/",
11
+ "runtime/",
11
12
  "README.md"
12
13
  ],
13
14
  "keywords": [
@@ -0,0 +1,3 @@
1
+ mcp>=1.0.0
2
+ httpx>=0.27.0
3
+ python-dotenv>=1.0.0
@@ -0,0 +1,915 @@
1
+ """
2
+ TasksAI MCP Server — Universal Multi-Vertical Router
3
+
4
+ A single MCP server that works for all 29 TasksAI verticals.
5
+ On startup, calls GET /v1/me to detect the vertical from the license key,
6
+ then self-configures tool names, system prompt, and abbreviation maps.
7
+
8
+ Tools (names are vertical-prefixed at runtime, e.g. farmertasksai_search):
9
+ {prefix}_search — Find the right skill for your task
10
+ {prefix}_execute — Get the full expert framework for a skill (costs 1 credit)
11
+ {prefix}_balance — Check your remaining credit balance
12
+ {prefix}_categories — Browse skills by category
13
+
14
+ Privacy: Your queries, documents, and client data never leave your machine.
15
+ Skills run entirely locally. The API only delivers skill metadata and
16
+ counts credits — it never sees what you're working on.
17
+ """
18
+
19
+ import os
20
+ import re
21
+ import sys
22
+ import time
23
+ import asyncio
24
+ import platform
25
+ import httpx
26
+ from pathlib import Path
27
+
28
+ # Force UTF-8 stdout/stderr on Windows (default is CP1252 which breaks emoji)
29
+ if sys.stdout and hasattr(sys.stdout, 'reconfigure'):
30
+ sys.stdout.reconfigure(encoding='utf-8', errors='replace')
31
+ if sys.stderr and hasattr(sys.stderr, 'reconfigure'):
32
+ sys.stderr.reconfigure(encoding='utf-8', errors='replace')
33
+ from dotenv import load_dotenv
34
+ from mcp.server import Server
35
+ from mcp.server.stdio import stdio_server
36
+ from mcp.types import Tool, TextContent
37
+
38
+ # ── .env resolution ──────────────────────────────────────────────────────────
39
+ # When running as a compiled binary (PyInstaller), the .env lives in the
40
+ # permanent install directory, not next to the executable.
41
+ # Search order: install dir → script/exe dir → cwd
42
+
43
+ def _find_dotenv() -> str | None:
44
+ """Return path to .env or None. Checks install dir first."""
45
+ system = platform.system()
46
+
47
+ # Determine app folder name from this binary's parent dir name,
48
+ # or fall back to checking all known vertical install dirs.
49
+ home = Path.home()
50
+ if system == "Windows":
51
+ local = Path(os.environ.get("LOCALAPPDATA", home / "AppData" / "Local"))
52
+ search_bases = [local]
53
+ elif system == "Darwin":
54
+ search_bases = [home / "Library" / "Application Support"]
55
+ else:
56
+ search_bases = [home / ".local" / "share"]
57
+
58
+ # Check parent of the running binary first (most specific)
59
+ exe_dir = Path(sys.executable).parent if getattr(sys, 'frozen', False) else Path(__file__).parent
60
+ candidate = exe_dir / ".env"
61
+ if candidate.exists():
62
+ return str(candidate)
63
+
64
+ # Search known TasksAI install dirs under the base
65
+ for base in search_bases:
66
+ if not base.exists():
67
+ continue
68
+ for child in base.iterdir():
69
+ if child.is_dir() and "tasksai" in child.name.lower():
70
+ candidate = child / ".env"
71
+ if candidate.exists():
72
+ return str(candidate)
73
+
74
+ return None
75
+
76
+
77
+ _dotenv_path = _find_dotenv()
78
+ if _dotenv_path:
79
+ load_dotenv(_dotenv_path)
80
+ else:
81
+ load_dotenv() # fallback: search cwd and parent dirs
82
+
83
+ API_BASE = os.getenv("TASKSAI_API_BASE", os.getenv("LAWTASKSAI_API_BASE", "https://api.lawtasksai.com"))
84
+ LICENSE_KEY = os.getenv("TASKSAI_LICENSE_KEY", os.getenv("LAWTASKSAI_LICENSE_KEY", ""))
85
+ PRODUCT_ID = os.getenv("TASKSAI_PRODUCT_ID", "") # set by installer; used to resolve correct vertical
86
+
87
+ if not LICENSE_KEY:
88
+ print("ERROR: License key is required. Set TASKSAI_LICENSE_KEY in your .env file.", file=sys.stderr, flush=True)
89
+ print("Find your key in your purchase confirmation email.", file=sys.stderr, flush=True)
90
+ sys.exit(1)
91
+
92
+ SERVER_VERSION = "2.1.0"
93
+
94
+ AUTH_HEADERS = {
95
+ "Authorization": f"Bearer {LICENSE_KEY}",
96
+ "Content-Type": "application/json",
97
+ "X-Client-Type": "mcp-server",
98
+ "X-Client-Version": SERVER_VERSION,
99
+ }
100
+
101
+ # ── Per-vertical abbreviation maps ────────────────────────────────────────────
102
+ # Expands common shorthand before trigger-phrase matching.
103
+ # Fallback abbreviation maps used when GET /v1/abbreviations is unavailable.
104
+ # Sprint 6: DB is now the source of truth; these are a safety net only.
105
+
106
+ _ABBREVS_FALLBACK = {
107
+ "law": {
108
+ "mtc": "motion to compel",
109
+ "rogs": "interrogatories",
110
+ "rog": "interrogatory",
111
+ "rfa": "request for admission",
112
+ "rfas": "requests for admission",
113
+ "rfp": "request for production",
114
+ "rfps": "requests for production",
115
+ "tro": "temporary restraining order",
116
+ "pi": "personal injury",
117
+ "msj": "motion for summary judgment",
118
+ "msk": "motion to strike",
119
+ "sj": "summary judgment",
120
+ "jnov": "judgment notwithstanding verdict",
121
+ "mil": "motion in limine",
122
+ "sol": "statute of limitations",
123
+ "aff": "affidavit",
124
+ "decl": "declaration",
125
+ "depo": "deposition",
126
+ "deps": "depositions",
127
+ "frcp": "federal rules civil procedure",
128
+ "fre": "federal rules evidence",
129
+ "compl": "complaint",
130
+ "ans": "answer",
131
+ "roe": "rules of evidence",
132
+ "atty": "attorney",
133
+ },
134
+ "realtor": {
135
+ "mls": "multiple listing service",
136
+ "cma": "comparative market analysis",
137
+ "dom": "days on market",
138
+ "arv": "after repair value",
139
+ "hoa": "homeowners association",
140
+ "coe": "close of escrow",
141
+ "emd": "earnest money deposit",
142
+ "piti": "principal interest taxes insurance",
143
+ "ltv": "loan to value",
144
+ "nar": "national association of realtors",
145
+ "bom": "back on market",
146
+ "uc": "under contract",
147
+ "fs": "for sale",
148
+ "fsbo": "for sale by owner",
149
+ "reo": "real estate owned",
150
+ },
151
+ "contractor": {
152
+ "rfi": "request for information",
153
+ "sow": "scope of work",
154
+ "co": "change order",
155
+ "gc": "general contractor",
156
+ "ntp": "notice to proceed",
157
+ "pco": "potential change order",
158
+ "aia": "american institute of architects",
159
+ "lien": "mechanics lien",
160
+ "sub": "subcontractor",
161
+ "por": "purchase order request",
162
+ "cos": "certificate of substantial completion",
163
+ "punch": "punch list",
164
+ "g702": "payment application",
165
+ "g703": "schedule of values",
166
+ },
167
+ "farmer": {
168
+ "fsa": "farm service agency",
169
+ "nrcs": "natural resources conservation service",
170
+ "crp": "conservation reserve program",
171
+ "arc": "agriculture risk coverage",
172
+ "plc": "price loss coverage",
173
+ "usda": "united states department of agriculture",
174
+ "eqip": "environmental quality incentives program",
175
+ "csa": "community supported agriculture",
176
+ "gmp": "good manufacturing practices",
177
+ "gap": "good agricultural practices",
178
+ },
179
+ "hr": {
180
+ "pip": "performance improvement plan",
181
+ "pto": "paid time off",
182
+ "fmla": "family medical leave act",
183
+ "ada": "americans with disabilities act",
184
+ "eeoc": "equal employment opportunity commission",
185
+ "w2": "wage and tax statement",
186
+ "i9": "employment eligibility verification",
187
+ "cobra": "consolidated omnibus budget reconciliation act",
188
+ "osha": "occupational safety and health administration",
189
+ "erp": "employee relations policy",
190
+ },
191
+ "accounting": {
192
+ "p&l": "profit and loss",
193
+ "cogs": "cost of goods sold",
194
+ "ar": "accounts receivable",
195
+ "ap": "accounts payable",
196
+ "gaap": "generally accepted accounting principles",
197
+ "ytd": "year to date",
198
+ "mtd": "month to date",
199
+ "ebitda": "earnings before interest taxes depreciation amortization",
200
+ "cpa": "certified public accountant",
201
+ "sox": "sarbanes oxley",
202
+ },
203
+ "mortgage": {
204
+ "ltv": "loan to value",
205
+ "dti": "debt to income",
206
+ "arm": "adjustable rate mortgage",
207
+ "apr": "annual percentage rate",
208
+ "pmi": "private mortgage insurance",
209
+ "hud": "housing and urban development",
210
+ "fnma": "fannie mae",
211
+ "fhlmc": "freddie mac",
212
+ "heloc": "home equity line of credit",
213
+ "gfe": "good faith estimate",
214
+ "cd": "closing disclosure",
215
+ "le": "loan estimate",
216
+ },
217
+ "insurance": {
218
+ "doi": "department of insurance",
219
+ "e&o": "errors and omissions",
220
+ "gl": "general liability",
221
+ "wc": "workers compensation",
222
+ "coi": "certificate of insurance",
223
+ "dec": "declarations page",
224
+ "aob": "assignment of benefits",
225
+ "uwi": "underwriting information",
226
+ "clue": "comprehensive loss underwriting exchange",
227
+ "pip": "personal injury protection",
228
+ },
229
+ "therapist": {
230
+ "dap": "data assessment plan",
231
+ "soap": "subjective objective assessment plan",
232
+ "hipaa": "health insurance portability and accountability act",
233
+ "phi": "protected health information",
234
+ "dx": "diagnosis",
235
+ "tx": "treatment",
236
+ "iop": "intensive outpatient program",
237
+ "php": "partial hospitalization program",
238
+ "cbt": "cognitive behavioral therapy",
239
+ "dbt": "dialectical behavior therapy",
240
+ "emdr": "eye movement desensitization reprocessing",
241
+ },
242
+ "chiropractor": {
243
+ "soap": "subjective objective assessment plan",
244
+ "rom": "range of motion",
245
+ "pi": "personal injury",
246
+ "hipaa": "health insurance portability and accountability act",
247
+ "icd": "international classification of diseases",
248
+ "cpt": "current procedural terminology",
249
+ "eob": "explanation of benefits",
250
+ },
251
+ "dentist": {
252
+ "hipaa": "health insurance portability and accountability act",
253
+ "cddt": "current dental terminology",
254
+ "perio": "periodontal",
255
+ "ortho": "orthodontic",
256
+ "endo": "endodontic",
257
+ "eob": "explanation of benefits",
258
+ "pano": "panoramic radiograph",
259
+ },
260
+ "teacher": {
261
+ "iep": "individualized education program",
262
+ "504": "section 504 accommodation plan",
263
+ "ell": "english language learner",
264
+ "sped": "special education",
265
+ "pbis": "positive behavioral interventions and supports",
266
+ "mtss": "multi-tiered system of supports",
267
+ "rti": "response to intervention",
268
+ "ferpa": "family educational rights and privacy act",
269
+ "pd": "professional development",
270
+ "plc": "professional learning community",
271
+ },
272
+ "vet": {
273
+ "soap": "subjective objective assessment plan",
274
+ "avma": "american veterinary medical association",
275
+ "rx": "prescription",
276
+ "dx": "diagnosis",
277
+ "tx": "treatment",
278
+ "hx": "history",
279
+ "pe": "physical examination",
280
+ },
281
+ "electrician": {
282
+ "nec": "national electrical code",
283
+ "gfci": "ground fault circuit interrupter",
284
+ "afci": "arc fault circuit interrupter",
285
+ "atp": "ampere trip point",
286
+ "rfi": "request for information",
287
+ "co": "change order",
288
+ "ntp": "notice to proceed",
289
+ },
290
+ "plumber": {
291
+ "ipc": "international plumbing code",
292
+ "upc": "uniform plumbing code",
293
+ "rfi": "request for information",
294
+ "co": "change order",
295
+ "ntp": "notice to proceed",
296
+ "pex": "cross-linked polyethylene",
297
+ "abs": "acrylonitrile butadiene styrene",
298
+ },
299
+ # ── Additional verticals ──────────────────────────────────────────────────
300
+ "marketing": {
301
+ "seo": "search engine optimization",
302
+ "sem": "search engine marketing",
303
+ "ppc": "pay per click",
304
+ "ctr": "click through rate",
305
+ "cpc": "cost per click",
306
+ "cpa": "cost per acquisition",
307
+ "roi": "return on investment",
308
+ "kpi": "key performance indicator",
309
+ "crm": "customer relationship management",
310
+ "cta": "call to action",
311
+ "b2b": "business to business",
312
+ "b2c": "business to consumer",
313
+ "saas": "software as a service",
314
+ "mrr": "monthly recurring revenue",
315
+ "arr": "annual recurring revenue",
316
+ },
317
+ "pastor": {
318
+ "vbs": "vacation bible school",
319
+ "awana": "approved workmen are not ashamed",
320
+ "acl": "adult community life",
321
+ "lcm": "leadership core meeting",
322
+ "sml": "small group leader",
323
+ },
324
+ "salon": {
325
+ "pbe": "professional beauty equipment",
326
+ "cosmo": "cosmetology",
327
+ "esti": "esthetician",
328
+ "nail": "nail technician",
329
+ "hsc": "hair salon coordinator",
330
+ },
331
+ "travelagent": {
332
+ "gds": "global distribution system",
333
+ "iata": "international air transport association",
334
+ "fam": "familiarization trip",
335
+ "fx": "foreign exchange",
336
+ "ota": "online travel agency",
337
+ "pnr": "passenger name record",
338
+ "roi": "return on investment",
339
+ },
340
+ "restaurant": {
341
+ "cogs": "cost of goods sold",
342
+ "foh": "front of house",
343
+ "boh": "back of house",
344
+ "pos": "point of sale",
345
+ "haccp": "hazard analysis critical control points",
346
+ "fifo": "first in first out",
347
+ "eighty six": "item unavailable",
348
+ },
349
+ "landlord": {
350
+ "noi": "net operating income",
351
+ "cap": "capitalization rate",
352
+ "roi": "return on investment",
353
+ "hoa": "homeowners association",
354
+ "sec dep": "security deposit",
355
+ "ltv": "loan to value",
356
+ },
357
+ "principal": {
358
+ "iep": "individualized education program",
359
+ "504": "section 504 accommodation plan",
360
+ "pbis": "positive behavioral interventions and supports",
361
+ "mtss": "multi-tiered system of supports",
362
+ "ferpa": "family educational rights and privacy act",
363
+ "sped": "special education",
364
+ "ell": "english language learner",
365
+ "plc": "professional learning community",
366
+ },
367
+ "mortuary": {
368
+ "fda": "food and drug administration",
369
+ "ftc": "federal trade commission",
370
+ "osha": "occupational safety and health administration",
371
+ "dna": "do not autopsy",
372
+ "dnr": "do not resuscitate",
373
+ },
374
+ "eventplanner": {
375
+ "rsvp": "repondez sil vous plait",
376
+ "av": "audio visual",
377
+ "beo": "banquet event order",
378
+ "rfp": "request for proposal",
379
+ "roi": "return on investment",
380
+ "f&b": "food and beverage",
381
+ },
382
+ "church": {
383
+ "vbs": "vacation bible school",
384
+ "awana": "approved workmen are not ashamed",
385
+ "501c3": "nonprofit tax exempt status",
386
+ "aed": "automated external defibrillator",
387
+ "acl": "adult community life",
388
+ },
389
+ "personaltrainer": {
390
+ "rm": "repetition maximum",
391
+ "hiit": "high intensity interval training",
392
+ "bmr": "basal metabolic rate",
393
+ "tdee": "total daily energy expenditure",
394
+ "bmi": "body mass index",
395
+ "rom": "range of motion",
396
+ "par q": "physical activity readiness questionnaire",
397
+ },
398
+ "designer": {
399
+ "ui": "user interface",
400
+ "ux": "user experience",
401
+ "rgb": "red green blue",
402
+ "cmyk": "cyan magenta yellow key",
403
+ "dpi": "dots per inch",
404
+ "ppi": "pixels per inch",
405
+ "svg": "scalable vector graphics",
406
+ "sow": "scope of work",
407
+ },
408
+ "militaryspouse": {
409
+ "pcs": "permanent change of station",
410
+ "tdy": "temporary duty assignment",
411
+ "bah": "basic allowance for housing",
412
+ "bas": "basic allowance for subsistence",
413
+ "deers": "defense enrollment eligibility reporting system",
414
+ "tricare":"military health insurance",
415
+ "id card":"military dependent identification",
416
+ },
417
+ "funeral": {
418
+ "ftc": "federal trade commission",
419
+ "fda": "food and drug administration",
420
+ "osha": "occupational safety and health administration",
421
+ "dnr": "do not resuscitate",
422
+ "cremains":"cremated remains",
423
+ },
424
+ "nutritionist": {
425
+ "bmi": "body mass index",
426
+ "bmr": "basal metabolic rate",
427
+ "tdee": "total daily energy expenditure",
428
+ "gi": "glycemic index",
429
+ "gl": "glycemic load",
430
+ "dri": "dietary reference intake",
431
+ "rda": "recommended dietary allowance",
432
+ "ibw": "ideal body weight",
433
+ },
434
+ }
435
+
436
+ # Default empty map for verticals without specific abbreviations
437
+ _DEFAULT_ABBREVS = {}
438
+
439
+ # DB-loaded abbreviations (fetched from /v1/abbreviations at startup)
440
+ _abbrevs_db: dict | None = None
441
+ _abbrevs_db_ts: float = 0.0
442
+ _abbrevs_db_product: str | None = None
443
+
444
+
445
+ # ── Cache configuration ────────────────────────────────────────────────────────
446
+ CACHE_TTL = 600 # 10 minutes
447
+ ERROR_COOLDOWN = 30 # retry after failure
448
+
449
+ # Vertical metadata (loaded once at startup via GET /v1/me)
450
+ _vertical = None
451
+
452
+ # Skills cache
453
+ _skills_cache = None
454
+ _skills_cache_ts = 0.0
455
+ _skills_cache_err_until = 0.0
456
+
457
+ # Triggers cache — {skill_id: [phrase, ...]}
458
+ _triggers_cache = None
459
+ _triggers_cache_ts = 0.0
460
+ _triggers_cache_err_until = 0.0
461
+
462
+
463
+ async def api_get(path):
464
+ async with httpx.AsyncClient(timeout=30.0) as client:
465
+ resp = await client.get(
466
+ f"{API_BASE}{path}",
467
+ headers={**AUTH_HEADERS, "X-Product-ID": (_vertical or {}).get("product_id", "law")}
468
+ )
469
+ resp.raise_for_status()
470
+ return resp.json()
471
+
472
+
473
+ async def load_vertical():
474
+ """Fetch vertical metadata from /v1/me on startup. Falls back to farmer."""
475
+ global _vertical
476
+ try:
477
+ path = f"/v1/me?product_id={PRODUCT_ID}" if PRODUCT_ID else "/v1/me"
478
+ _vertical = await api_get(path)
479
+ except Exception:
480
+ # Fallback: derive from license key prefix client-side
481
+ prefix = LICENSE_KEY.split("_")[0] + "_" if "_" in LICENSE_KEY else "ft_"
482
+ _vertical = {
483
+ "product_id": "farmer",
484
+ "product_name": "FarmerTasksAI",
485
+ "display_name": "FarmerTasksAI",
486
+ "tool_prefix": "farmertasksai",
487
+ "occupation": "farmer",
488
+ "support_email":"support@farmertasksai.com",
489
+ "domain": "farmertasksai.com",
490
+ }
491
+ return _vertical
492
+
493
+
494
+ async def load_abbreviations():
495
+ """
496
+ Fetch abbreviations for this vertical from GET /v1/abbreviations.
497
+ Populates _abbrevs_db. Falls back silently to _ABBREVS_FALLBACK if unavailable.
498
+ Called once at startup after load_vertical().
499
+ """
500
+ global _abbrevs_db, _abbrevs_db_ts, _abbrevs_db_product
501
+ try:
502
+ data = await api_get("/v1/abbreviations")
503
+ _abbrevs_db = data.get("abbreviations", {})
504
+ _abbrevs_db_product = data.get("product_id")
505
+ _abbrevs_db_ts = time.monotonic()
506
+ except Exception:
507
+ # Non-fatal: _ABBREVS_FALLBACK will be used instead
508
+ _abbrevs_db = None
509
+ return _abbrevs_db
510
+
511
+
512
+ async def get_skills():
513
+ global _skills_cache, _skills_cache_ts, _skills_cache_err_until
514
+ now = time.monotonic()
515
+ if _skills_cache is not None and (now - _skills_cache_ts) < CACHE_TTL:
516
+ return _skills_cache
517
+ if now < _skills_cache_err_until:
518
+ return _skills_cache if _skills_cache is not None else []
519
+ try:
520
+ _skills_cache = await api_get("/v1/skills")
521
+ _skills_cache_ts = now
522
+ _skills_cache_err_until = 0.0
523
+ except Exception:
524
+ _skills_cache_err_until = now + ERROR_COOLDOWN
525
+ if _skills_cache is None:
526
+ _skills_cache = []
527
+ return _skills_cache
528
+
529
+
530
+ async def get_triggers():
531
+ """Return trigger phrases {skill_id: [phrase, ...]}. Fails silently."""
532
+ global _triggers_cache, _triggers_cache_ts, _triggers_cache_err_until
533
+ now = time.monotonic()
534
+ if _triggers_cache is not None and (now - _triggers_cache_ts) < CACHE_TTL:
535
+ return _triggers_cache
536
+ if now < _triggers_cache_err_until:
537
+ return _triggers_cache if _triggers_cache is not None else {}
538
+ try:
539
+ raw = await api_get("/v1/skills/triggers")
540
+ _triggers_cache = {
541
+ sid: [p.lower() for p in v.get("triggers", [])]
542
+ for sid, v in raw.items()
543
+ }
544
+ _triggers_cache_ts = now
545
+ _triggers_cache_err_until = 0.0
546
+ except Exception:
547
+ _triggers_cache_err_until = now + ERROR_COOLDOWN
548
+ if _triggers_cache is None:
549
+ _triggers_cache = {}
550
+ return _triggers_cache
551
+
552
+
553
+ def expand_query(query, product_id):
554
+ """Expand vertical-specific abbreviations before matching."""
555
+ # Prefer DB-loaded abbreviations; fall back to hardcoded map
556
+ if _abbrevs_db is not None:
557
+ abbrevs = _abbrevs_db
558
+ else:
559
+ abbrevs = _ABBREVS_FALLBACK.get(product_id, _DEFAULT_ABBREVS)
560
+ if not abbrevs:
561
+ return query
562
+ words = query.lower().split()
563
+ expansions = [abbrevs[w.strip(".,;:?!")] for w in words if w.strip(".,;:?!") in abbrevs]
564
+ return (query + " " + " ".join(expansions)).strip() if expansions else query
565
+
566
+
567
+ def _word_in_text(word, text):
568
+ """True if `word` appears as a whole word in `text`."""
569
+ return bool(re.search(r'(?<!\w)' + re.escape(word) + r'(?!\w)', text))
570
+
571
+
572
+ def score_skill(skill, query_lower, query_words, triggers):
573
+ """Three-tier scoring: trigger match (10) > name match (3) > description match (1)."""
574
+ skill_id = skill.get("id", "")
575
+ name_text = skill.get("name", "").lower()
576
+ desc_text = skill.get("description", "").lower()
577
+ full_text = name_text + " " + desc_text
578
+ # Tier 1 — trigger phrase (whole-word, bidirectional)
579
+ for phrase in triggers.get(skill_id, []):
580
+ if _word_in_text(phrase, query_lower) or _word_in_text(query_lower, phrase):
581
+ return 10
582
+ # Tier 2 — keyword
583
+ return sum(
584
+ 3 if _word_in_text(w, name_text) else 1
585
+ for w in query_words
586
+ if _word_in_text(w, full_text)
587
+ )
588
+
589
+
590
+ def build_tools(prefix, product_name, occupation):
591
+ """Build the four MCP tools with vertical-specific names and descriptions."""
592
+ # Build example queries from the top trigger phrases for this vertical
593
+ _examples = {
594
+ "attorney": "e.g. 'statute of limitations', 'motion to compel', 'demand letter', 'deposition prep', 'discovery requests'",
595
+ "realtor": "e.g. 'listing presentation', 'buyer consultation', 'CMA analysis', 'open house checklist'",
596
+ "farmer": "e.g. 'crop insurance claim', 'USDA loan application', 'conservation plan', 'farm succession'",
597
+ "teacher": "e.g. 'IEP meeting', 'lesson plan', 'parent conference', 'classroom management'",
598
+ "therapist": "e.g. 'treatment plan', 'SOAP note', 'intake assessment', 'crisis intervention'",
599
+ "contractor": "e.g. 'change order', 'bid proposal', 'punch list', 'lien waiver'",
600
+ "marketing": "e.g. 'content calendar', 'SEO audit', 'email campaign', 'brand strategy'",
601
+ }
602
+ occ_key = occupation.split(',')[0].strip()
603
+ audience = normalize_audience_label(occ_key)
604
+ domain_adjective = normalize_domain_adjective(occ_key)
605
+ examples = _examples.get(domain_adjective, _examples.get(product_name.replace('TasksAI','').lower().strip(), f"e.g. a {domain_adjective} task"))
606
+
607
+ return [
608
+ Tool(
609
+ name=f"{prefix}_search",
610
+ description=(
611
+ f"Search {product_name}'s library of interactive guided workflows for {audience}. "
612
+ f"Each workflow walks the user through a structured, multi-step process with intake questions, "
613
+ f"domain-specific checklists, recordkeeping steps, and document generation templates "
614
+ f"that cannot be replicated from general knowledge alone. "
615
+ f"Use this tool whenever the user has a {domain_adjective}-related question or task — "
616
+ f"even simple-sounding requests may have dedicated workflows with practical requirements, "
617
+ f"review points, and fillable templates. "
618
+ f"Returns a numbered list of matching workflows with descriptions."
619
+ ),
620
+ inputSchema={
621
+ "type": "object",
622
+ "properties": {
623
+ "query": {
624
+ "type": "string",
625
+ "description": f"{domain_adjective.title()} topic or task ({examples})"
626
+ }
627
+ },
628
+ "required": ["query"]
629
+ }
630
+ ),
631
+ Tool(
632
+ name=f"{prefix}_execute",
633
+ description=(
634
+ f"Launch a {product_name} guided workflow by its ID. Returns the full interactive framework "
635
+ f"including intake questions, step-by-step instructions, domain-specific guidance, "
636
+ f"and document templates. Costs 1 credit. Use {prefix}_search first to find the workflow ID."
637
+ ),
638
+ inputSchema={
639
+ "type": "object",
640
+ "properties": {
641
+ "skill_id": {
642
+ "type": "string",
643
+ "description": f"Workflow ID from {prefix}_search results"
644
+ }
645
+ },
646
+ "required": ["skill_id"]
647
+ }
648
+ ),
649
+ Tool(
650
+ name=f"{prefix}_balance",
651
+ description=f"Check your remaining {product_name} credit balance.",
652
+ inputSchema={"type": "object", "properties": {}}
653
+ ),
654
+ Tool(
655
+ name=f"{prefix}_categories",
656
+ description=(
657
+ f"Browse all {product_name} workflow categories. "
658
+ "Use when the user isn't sure what to search for, "
659
+ "or when a search returns no results."
660
+ ),
661
+ inputSchema={"type": "object", "properties": {}}
662
+ ),
663
+ ]
664
+
665
+
666
+ def normalize_audience_label(label):
667
+ """Return a natural audience phrase without naive pluralization."""
668
+ label = (label or "professionals").strip()
669
+ if label in {"attorney", "lawyer"}:
670
+ return f"{label}s"
671
+ if label == "farmer":
672
+ return "farmers, ranchers, and agricultural operations managers"
673
+ if label in {"legal", "law"}:
674
+ return "legal professionals"
675
+ return label
676
+
677
+
678
+ def normalize_domain_adjective(label):
679
+ """Return a natural adjective for tool descriptions and query labels."""
680
+ label = (label or "professional").strip()
681
+ if label in {"attorneys and legal professionals", "legal professionals", "attorney", "lawyer", "law"}:
682
+ return "legal"
683
+ return label
684
+
685
+
686
+ def build_system_prompt(product_name, occupation, prefix, domain, support_email):
687
+ return f"""You are a {product_name} assistant for {occupation}.
688
+
689
+ When the user asks any {occupation.split(',')[0]}-related question, call {prefix}_search first to find a matching expert framework. The user is paying for {product_name}'s expert-built frameworks — always search before answering.
690
+
691
+ Workflow:
692
+ 1. Call {prefix}_search with the user's question
693
+ 2. Present the numbered results
694
+ 3. Let the user choose, then call {prefix}_execute with the skill_id (costs 1 credit)
695
+ 4. Show remaining credit balance
696
+
697
+ Rules:
698
+ - Always search first, even for simple questions
699
+ - Never call {prefix}_execute without user confirmation
700
+ - If no results, suggest {prefix}_categories
701
+
702
+ {domain} | Support: {support_email}"""
703
+
704
+
705
+ # ── Server initialization ──────────────────────────────────────────────────────
706
+ # Note: MCP server tools are registered at module load time, but we need
707
+ # vertical metadata from the API. We use a two-phase init:
708
+ # Phase 1: create server with placeholder tools (farmer defaults)
709
+ # Phase 2: on first tool call, ensure vertical is loaded and tools are current
710
+
711
+ # Placeholder system prompt (overwritten after /v1/me loads)
712
+ _system_prompt_text = build_system_prompt(
713
+ "FarmerTasksAI", "farmers, ranchers, and agricultural operations managers",
714
+ "farmertasksai", "farmertasksai.com", "support@farmertasksai.com"
715
+ )
716
+
717
+ # NOTE: Do NOT pass instructions= here. Testing showed that Claude Desktop
718
+ # v1.9+ ignores or deprioritizes MCP server instructions. The working March
719
+ # config had no instructions and no prompts — just clean tool descriptions.
720
+ server = Server("farmertasksai")
721
+
722
+ # Placeholder tools using farmer defaults (overwritten after /v1/me loads)
723
+ _tools = build_tools("farmertasksai", "FarmerTasksAI", "farmer")
724
+
725
+ # NOTE: Prompts capability intentionally removed. The working March 2026
726
+ # config had no prompts — just tools. Adding prompts may cause Claude Desktop
727
+ # to deprioritize tool auto-invocation.
728
+
729
+
730
+ @server.list_tools()
731
+ async def list_tools():
732
+ # Ensure vertical is loaded before advertising tools
733
+ if _vertical is None:
734
+ await load_vertical()
735
+ await load_abbreviations()
736
+ _rebuild_tools()
737
+ return _tools
738
+
739
+
740
+ def _rebuild_tools():
741
+ """Rebuild tools and system prompt once vertical metadata is available."""
742
+ global _tools, _system_prompt_text
743
+ if _vertical is None:
744
+ return
745
+ prefix = _vertical.get("tool_prefix", "farmertasksai")
746
+ name = _vertical.get("product_name", "FarmerTasksAI")
747
+ occ = _vertical.get("occupation", "professionals")
748
+ domain = _vertical.get("domain", "farmertasksai.com")
749
+ support = _vertical.get("support_email", "support@farmertasksai.com")
750
+ _tools = build_tools(prefix, name, occ)
751
+ _system_prompt_text = build_system_prompt(name, occ, prefix, domain, support)
752
+
753
+
754
+ @server.call_tool()
755
+ async def call_tool(name, arguments):
756
+ # Ensure vertical loaded on first tool call
757
+ if _vertical is None:
758
+ await load_vertical()
759
+ await load_abbreviations()
760
+ _rebuild_tools()
761
+
762
+ v = _vertical or {}
763
+ prefix = v.get("tool_prefix", "farmertasksai")
764
+ product_id = v.get("product_id", "farmer")
765
+ product_name = v.get("product_name", "FarmerTasksAI")
766
+ occupation = v.get("occupation", "professionals")
767
+
768
+ try:
769
+ # ── Search ────────────────────────────────────────────────────────────
770
+ if name == f"{prefix}_search":
771
+ skills, triggers = await get_skills(), await get_triggers()
772
+ query = expand_query(arguments.get("query", ""), product_id)
773
+ query_lower = query.lower()
774
+ STOP_WORDS = {"a","an","the","and","or","of","in","to","for","is","are",
775
+ "with","at","by","on","from","as","it","its","be","was","can"}
776
+ raw_words = query.split()
777
+ query_words = [
778
+ w_lower for w_orig, w_lower in zip(raw_words, query_lower.split())
779
+ if w_lower not in STOP_WORDS and (len(w_lower) > 2 or w_orig.isupper())
780
+ ]
781
+ scored = [(score_skill(s, query_lower, query_words, triggers), s) for s in skills]
782
+ scored = [(sc, s) for sc, s in scored if sc > 0]
783
+ scored.sort(key=lambda x: -x[0])
784
+ matches = [s for _, s in scored[:5]]
785
+
786
+ if not matches:
787
+ return [TextContent(type="text", text=(
788
+ f"No skills found matching **'{arguments.get('query', '')}'**.\n\n"
789
+ "**Suggestions:**\n"
790
+ "- Try different keywords or a more specific phrase\n"
791
+ f"- Use `{prefix}_categories` to browse all skill categories\n"
792
+ "- Ask the user to rephrase their request\n\n"
793
+ f"**DO NOT call `{prefix}_execute`** — no skill has been selected."
794
+ ))]
795
+
796
+ lines = [f"**{len(matches)} skills found for '{arguments.get('query', '')}':**\n"]
797
+ for i, s in enumerate(matches, 1):
798
+ desc = s.get("description", "")[:100]
799
+ lines.append(f"{i}. **{s['name']}** (`{s['id']}`)\n {desc}\n")
800
+
801
+ lines.append("---")
802
+ lines.append(
803
+ "**\U0001f6d1 REQUIRED \u2014 DO NOT SKIP:**\n"
804
+ "Present the numbered list above to the user EXACTLY as shown. "
805
+ "Then ask: *\"Which of these best fits your situation? "
806
+ "(Reply with a number, or describe your task differently and I'll search again.)\"*\n\n"
807
+ f"**DO NOT call `{prefix}_execute` until the user replies with their choice. "
808
+ "Each execution costs 1 credit and cannot be undone.**"
809
+ )
810
+ return [TextContent(type="text", text="\n".join(lines))]
811
+
812
+ # ── Execute ───────────────────────────────────────────────────────────
813
+ elif name == f"{prefix}_execute":
814
+ skill_id = arguments.get("skill_id", "")
815
+ if not skill_id:
816
+ return [TextContent(type="text", text="Error: skill_id is required.")]
817
+
818
+ result = await api_get(f"/v1/skills/{skill_id}/execute")
819
+
820
+ content = result.get("schema", result.get("content", ""))
821
+ skill_name = result.get("skill_name", skill_id)
822
+ credits_remaining = result.get("credits_remaining", "?")
823
+
824
+ return [TextContent(type="text", text=(
825
+ f"# {skill_name}\n\n"
826
+ f"{content}\n\n"
827
+ f"---\n*Credits remaining: {credits_remaining}*"
828
+ ))]
829
+
830
+ # ── Balance ───────────────────────────────────────────────────────────
831
+ elif name == f"{prefix}_balance":
832
+ result = await api_get("/v1/credits/balance")
833
+ balance = result.get("credits_balance", "?")
834
+ lic_type = result.get("license_type", "")
835
+ domain = v.get("domain", "farmertasksai.com")
836
+ return [TextContent(type="text", text=(
837
+ f"**{product_name} Credits**\n\n"
838
+ f"- Balance: **{balance} credits**\n"
839
+ f"- License type: {lic_type}\n"
840
+ f"- MCP server version: {SERVER_VERSION}\n\n"
841
+ f"Purchase more at: https://{domain}/#pricing"
842
+ ))]
843
+
844
+ # ── Categories ────────────────────────────────────────────────────────
845
+ elif name == f"{prefix}_categories":
846
+ skills = await get_skills()
847
+ cats: dict[str, int] = {}
848
+ for s in skills:
849
+ cat = s.get("category_id") or s.get("category", "General")
850
+ cats[cat] = cats.get(cat, 0) + 1
851
+ cats_sorted = sorted(cats.items(), key=lambda x: -x[1])
852
+ lines = [f"**{product_name} Skill Categories** ({len(skills)} total skills)\n"]
853
+ for cat, count in cats_sorted:
854
+ lines.append(f"- **{cat}** ({count} skills)")
855
+ lines.append(f"\nSearch within any category using `{prefix}_search`.")
856
+ return [TextContent(type="text", text="\n".join(lines))]
857
+
858
+ else:
859
+ return [TextContent(type="text", text=f"Unknown tool: {name}")]
860
+
861
+ except httpx.HTTPStatusError as e:
862
+ if e.response.status_code == 402:
863
+ domain = v.get("domain", "farmertasksai.com")
864
+ return [TextContent(type="text", text=(
865
+ f"**Insufficient credits.**\n\n"
866
+ f"Purchase more at: https://{domain}/#pricing"
867
+ ))]
868
+ elif e.response.status_code == 401:
869
+ return [TextContent(type="text", text=(
870
+ "**Invalid or expired license key.**\n\n"
871
+ "Check your purchase confirmation email or contact support."
872
+ ))]
873
+ return [TextContent(type="text", text=f"API error: {e.response.status_code}")]
874
+ except Exception as e:
875
+ return [TextContent(type="text", text=f"Error: {str(e)}")]
876
+
877
+
878
+ async def _ping_first_connection():
879
+ """Fire-and-forget: tell the API this license just connected for the first time."""
880
+ try:
881
+ async with httpx.AsyncClient(timeout=5.0) as client:
882
+ await client.get(
883
+ f"{API_BASE}/track/first-connection",
884
+ params={"license_key": LICENSE_KEY}
885
+ )
886
+ except Exception:
887
+ pass # non-fatal — never block startup
888
+
889
+
890
+ async def main():
891
+ # Load vertical metadata + abbreviations before accepting connections
892
+ await load_vertical()
893
+ await load_abbreviations()
894
+ _rebuild_tools()
895
+
896
+ # Ping first-connection tracker (idempotent — API only records it once)
897
+ asyncio.create_task(_ping_first_connection())
898
+
899
+ v = _vertical or {}
900
+ abbrev_count = len(_abbrevs_db) if _abbrevs_db is not None else 0
901
+ abbrev_src = "db" if _abbrevs_db is not None else "fallback"
902
+ # MCP uses stdout for JSON-RPC — all logging MUST go to stderr
903
+ import sys as _sys
904
+ print(f"[OK] {v.get('product_name', 'TasksAI')} MCP Server ready (v{SERVER_VERSION})", file=_sys.stderr, flush=True)
905
+ print(f" Abbreviations: {abbrev_count} loaded from {abbrev_src}", file=_sys.stderr, flush=True)
906
+ print(f" Vertical: {v.get('product_id', 'unknown')} | "
907
+ f"Tools: {v.get('tool_prefix', 'tasksai')}_search / execute / balance / categories",
908
+ file=_sys.stderr, flush=True)
909
+
910
+ async with stdio_server() as (read_stream, write_stream):
911
+ await server.run(read_stream, write_stream, server.create_initialization_options())
912
+
913
+
914
+ if __name__ == "__main__":
915
+ asyncio.run(main())
package/src/index.js CHANGED
@@ -8,11 +8,15 @@ import path from "node:path";
8
8
  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
+ import { fileURLToPath } from "node:url";
11
12
 
12
- const INSTALLER_VERSION = "0.1.7";
13
+ const INSTALLER_VERSION = "0.1.9";
14
+ const INSTALLER_DIR = path.dirname(fileURLToPath(import.meta.url));
15
+ const BUNDLED_RUNTIME_DIR = path.resolve(INSTALLER_DIR, "..", "runtime");
13
16
  const DEFAULT_SOURCES = {
14
17
  lawtasksai: "https://github.com/laudoluxDev/lawtasksai-mcp",
15
18
  farmer: "https://github.com/laudoluxDev/farmertasksai-mcp",
19
+ teacher: "https://github.com/laudoluxDev/teachertasksai-mcp",
16
20
  priorauthai: "https://github.com/laudoluxDev/priorauthai-mcp"
17
21
  };
18
22
 
@@ -178,11 +182,12 @@ async function install(options, { updateOnly = false } = {}) {
178
182
  await writeJson(path.join(installDir, "vertical.json"), vertical);
179
183
  await downloadRuntime(source, runtimeDir);
180
184
 
185
+ const licenseKey = await resolveLicenseKey(options, vertical, installDir);
186
+
181
187
  if (!options.skipPythonDeps) {
182
188
  installPythonDeps(runtimeDir, vendorDir);
183
189
  }
184
190
 
185
- const licenseKey = await resolveLicenseKey(options, vertical, installDir);
186
191
  const envEntries = {
187
192
  TASKSAI_LICENSE_KEY: licenseKey,
188
193
  LAWTASKSAI_LICENSE_KEY: licenseKey,
@@ -440,10 +445,11 @@ async function preflightWriteAccess({ operation, installDir, clients, options, v
440
445
 
441
446
  if (!failures.length) return;
442
447
 
443
- throw new Error(formatPermissionRecovery({ operation, failures, options, vertical, source }));
448
+ const recoveryScript = await writeRecoveryScript({ operation, options, source });
449
+ throw new Error(formatPermissionRecovery({ operation, failures, options, vertical, source, recoveryScript }));
444
450
  }
445
451
 
446
- function formatPermissionRecovery({ operation, failures, options, vertical, source }) {
452
+ function formatPermissionRecovery({ operation, failures, options, vertical, source, recoveryScript }) {
447
453
  const productName = vertical?.display_name || options.productId;
448
454
  const clientNames = resolveClients(options.client).map((client) => client.displayName).join(", ");
449
455
  const blockedPaths = failures
@@ -464,6 +470,7 @@ function formatPermissionRecovery({ operation, failures, options, vertical, sour
464
470
  "",
465
471
  "To finish setup, run this official installer command directly in Terminal:",
466
472
  installCommand,
473
+ recoveryScript ? `Recovery script: ${recoveryScript}` : null,
467
474
  "",
468
475
  "Then restart your AI app and run this health check:",
469
476
  doctorCommand,
@@ -476,20 +483,52 @@ function formatPermissionRecovery({ operation, failures, options, vertical, sour
476
483
  ].filter(Boolean).join("\n");
477
484
  }
478
485
 
486
+ async function writeRecoveryScript({ operation, options, source }) {
487
+ if (operation === "uninstall") return null;
488
+ const productId = String(options.productId || "tasksai").replace(/[^A-Za-z0-9_-]/g, "");
489
+ const scriptPath = path.join(os.tmpdir(), `${productId || "tasksai"}-finish-install.command`);
490
+ const installCommand = buildInstallCommand({ options, source });
491
+ const doctorCommand = buildDoctorCommand(options);
492
+ const body = [
493
+ "#!/bin/sh",
494
+ "set -e",
495
+ "echo 'Finishing TasksAI setup...'",
496
+ installCommand,
497
+ "echo",
498
+ "echo 'Running health check...'",
499
+ doctorCommand,
500
+ "echo",
501
+ "echo 'Done. Restart your AI app to load the MCP tools.'",
502
+ ""
503
+ ].join("\n");
504
+
505
+ try {
506
+ await fsp.writeFile(scriptPath, body, { mode: 0o755 });
507
+ await fsp.chmod(scriptPath, 0o755);
508
+ return scriptPath;
509
+ } catch {
510
+ return null;
511
+ }
512
+ }
513
+
479
514
  function buildInstallCommand({ options, source }) {
480
- const parts = ["npm", "exec", "--package=@tasksai/install", "--", "tasksai-install", options.productId];
515
+ const cliParts = ["tasksai-install", options.productId];
481
516
  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(" ");
517
+ if (sourceUrl) cliParts.push("--source", sourceUrl);
518
+ if (options.client) cliParts.push("--client", options.client);
519
+ if (options.installDir) cliParts.push("--install-dir", resolveUserPath(options.installDir));
520
+ return buildNpmCallCommand(cliParts);
486
521
  }
487
522
 
488
523
  function buildDoctorCommand(options) {
489
- const parts = ["npm", "exec", "--package=@tasksai/install", "--", "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(" ");
524
+ const cliParts = ["tasksai-install", options.productId, "doctor"];
525
+ if (options.client) cliParts.push("--client", options.client);
526
+ if (options.installDir) cliParts.push("--install-dir", resolveUserPath(options.installDir));
527
+ return buildNpmCallCommand(cliParts);
528
+ }
529
+
530
+ function buildNpmCallCommand(cliParts) {
531
+ return ["npm", "exec", "--package=@tasksai/install", "--call", cliParts.map(shellToken).join(" ")].map(shellToken).join(" ");
493
532
  }
494
533
 
495
534
  function shellToken(value) {
@@ -530,12 +569,23 @@ async function checkWritable({ label, targetPath, kind }) {
530
569
  }
531
570
 
532
571
  async function downloadRuntime(source, runtimeDir) {
533
- const serverText = await loadText(source, "server.py");
534
- const requirementsText = await loadText(source, "requirements.txt");
572
+ const serverText = await loadRuntimeText(source, "server.py");
573
+ const requirementsText = await loadRuntimeText(source, "requirements.txt");
535
574
  await fsp.writeFile(path.join(runtimeDir, "server.py"), serverText, "utf8");
536
575
  await fsp.writeFile(path.join(runtimeDir, "requirements.txt"), requirementsText, "utf8");
537
576
  }
538
577
 
578
+ async function loadRuntimeText(source, filePath) {
579
+ try {
580
+ return await loadText(source, filePath);
581
+ } catch (error) {
582
+ if ((source.kind === "github" && /HTTP 404/.test(error.message)) || error.code === "ENOENT") {
583
+ return fsp.readFile(path.join(BUNDLED_RUNTIME_DIR, filePath), "utf8");
584
+ }
585
+ throw error;
586
+ }
587
+ }
588
+
539
589
  function installPythonDeps(runtimeDir, vendorDir) {
540
590
  const python = findPython();
541
591
  if (!python) throw new Error("Python 3 is required but was not found on PATH.");
@@ -667,7 +717,7 @@ function verifySource({ options, source, manifest, vertical }) {
667
717
  const command = installer?.command;
668
718
  const args = installer?.args || [];
669
719
  const isLegacyNpx = command === "npx" && Array.isArray(args) && args[0] === "@tasksai/install";
670
- const isNpmExec = command === "npm" && Array.isArray(args) && args.includes("--package=@tasksai/install") && args.includes("tasksai-install");
720
+ const isNpmExec = command === "npm" && Array.isArray(args) && args.includes("--package=@tasksai/install") && (args.includes("tasksai-install") || args.includes("--call"));
671
721
  if (!isLegacyNpx && !isNpmExec) {
672
722
  throw new Error("Manifest installer command is not an approved @tasksai/install command.");
673
723
  }