eljay-ai 1.1.0

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 (64) hide show
  1. package/AGENTS.md +57 -0
  2. package/README.md +479 -0
  3. package/agent/__init__.py +10 -0
  4. package/agent/agent.py +269 -0
  5. package/agent/agents/__init__.py +34 -0
  6. package/agent/agents/registry.py +212 -0
  7. package/agent/builtin_tools.py +591 -0
  8. package/agent/chat.py +354 -0
  9. package/agent/config.py +67 -0
  10. package/agent/context.py +63 -0
  11. package/agent/edits.py +167 -0
  12. package/agent/gitaware.py +126 -0
  13. package/agent/hardware.py +331 -0
  14. package/agent/knowledge.py +133 -0
  15. package/agent/memory.py +98 -0
  16. package/agent/ollama_client.py +133 -0
  17. package/agent/permissions.py +93 -0
  18. package/agent/providers/__init__.py +51 -0
  19. package/agent/providers/base.py +78 -0
  20. package/agent/providers/image.py +110 -0
  21. package/agent/providers/ollama.py +111 -0
  22. package/agent/providers/video.py +96 -0
  23. package/agent/providers/web.py +155 -0
  24. package/agent/router.py +200 -0
  25. package/agent/rules.py +52 -0
  26. package/agent/runner.py +168 -0
  27. package/agent/skills.py +161 -0
  28. package/agent/tools.py +102 -0
  29. package/agent/verification.py +80 -0
  30. package/agent/workspace.py +487 -0
  31. package/bin/eljay +5 -0
  32. package/bin/eljay.cmd +4 -0
  33. package/bin/myagent +4 -0
  34. package/bin/myagent.cmd +4 -0
  35. package/eljay-ai-1.1.0.tgz +0 -0
  36. package/eljay.js +56 -0
  37. package/eljay.py +150 -0
  38. package/install.ps1 +28 -0
  39. package/knowledge/reference/diffusers.md +32 -0
  40. package/knowledge/reference/video-providers.md +21 -0
  41. package/knowledge/setup/comfyui.md +35 -0
  42. package/knowledge/setup/image-providers.md +16 -0
  43. package/knowledge/test-category/test-entry.md +5 -0
  44. package/myagent.py +30 -0
  45. package/package.json +40 -0
  46. package/skills/coding/code-review.md +3 -0
  47. package/skills/coding/fix-attempt-protocol.md +9 -0
  48. package/skills/debugging/root-cause-analysis.md +9 -0
  49. package/skills/general/communication.md +7 -0
  50. package/skills/laravel/authentication.md +15 -0
  51. package/skills/mysql/performance.md +9 -0
  52. package/skills/php/standards.md +8 -0
  53. package/skills/react/component-best-practices.md +8 -0
  54. package/skills/research/source-tracking.md +7 -0
  55. package/skills/security/input-validation.md +9 -0
  56. package/skills/testing/pytest-best-practices.md +7 -0
  57. package/tests/run_all.py +34 -0
  58. package/tests/test_agent_core.py +385 -0
  59. package/tests/test_capabilities.py +169 -0
  60. package/tests/test_edits.py +117 -0
  61. package/tests/test_eljay.py +209 -0
  62. package/tests/test_runner.py +114 -0
  63. package/tests/test_universal.py +458 -0
  64. package/tests/test_workspace.py +221 -0
@@ -0,0 +1,487 @@
1
+ """Project workspace: root detection, stack detection, and safe file listing.
2
+
3
+ Everything here is READ-ONLY. It never modifies the project. Paths are confined
4
+ to the project root so the agent cannot wander into unrelated directories.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import json
10
+ import os
11
+ import re
12
+ from collections.abc import Iterator
13
+ from dataclasses import dataclass
14
+ from itertools import islice
15
+ from pathlib import Path
16
+
17
+ # Directories we never descend into (keeps output small and relevant).
18
+ DEFAULT_IGNORES = frozenset(
19
+ {
20
+ ".git", ".hg", ".svn",
21
+ "node_modules", "bower_components", "vendor",
22
+ "__pycache__", ".venv", "venv", "env",
23
+ "dist", "build", ".next", ".nuxt", "out",
24
+ ".idea", ".vscode", ".vs",
25
+ "target", "coverage", ".pytest_cache", ".mypy_cache", ".ruff_cache",
26
+ ".cache", "bin", "obj",
27
+ }
28
+ )
29
+
30
+ # Files that mark the top of a project.
31
+ PROJECT_MARKERS = (
32
+ ".git", "package.json", "composer.json", "pyproject.toml",
33
+ "requirements.txt", "setup.py", "go.mod", "Cargo.toml", "pom.xml",
34
+ )
35
+
36
+ # How far up the tree to look for a project root.
37
+ MAX_ROOT_SEARCH_DEPTH = 6
38
+
39
+ # Upper bound on entries returned by list_files, to protect the model's context.
40
+ MAX_ENTRIES_LIMIT = 2000
41
+
42
+ # Read limits for read_file.
43
+ MAX_READ_BYTES = 1_000_000 # refuse whole-file reads larger than this
44
+ MAX_READ_LINES = 2000 # cap number of lines returned in one call
45
+ BINARY_SNIFF_BYTES = 8192 # bytes checked to decide if a file is binary
46
+
47
+ # Search limits for search_code.
48
+ MAX_SEARCH_FILE_BYTES = 2_000_000 # skip files larger than this
49
+ MAX_SEARCH_RESULTS = 500 # hard cap on matches returned
50
+ SEARCH_LINE_WIDTH = 200 # max characters shown per matching line
51
+
52
+
53
+ def human_size(num_bytes: int) -> str:
54
+ size = float(num_bytes)
55
+ for unit in ("B", "KB", "MB", "GB"):
56
+ if size < 1024 or unit == "GB":
57
+ if unit == "B":
58
+ return f"{int(size)} {unit}"
59
+ return f"{size:.1f} {unit}"
60
+ size /= 1024
61
+ return f"{size:.1f} GB"
62
+
63
+
64
+ def find_project_root(start: Path) -> Path:
65
+ """Walk up from `start` looking for a project marker; fall back to `start`."""
66
+ current = Path(start).resolve()
67
+ for _ in range(MAX_ROOT_SEARCH_DEPTH + 1):
68
+ if any((current / marker).exists() for marker in PROJECT_MARKERS):
69
+ return current
70
+ if current.parent == current: # filesystem root
71
+ break
72
+ current = current.parent
73
+ return Path(start).resolve()
74
+
75
+
76
+ @dataclass
77
+ class Workspace:
78
+ """The project the agent is allowed to inspect."""
79
+
80
+ root: Path
81
+
82
+ def __post_init__(self) -> None:
83
+ self.root = Path(self.root).resolve()
84
+
85
+ @property
86
+ def name(self) -> str:
87
+ return self.root.name
88
+
89
+ def resolve(self, rel: str) -> Path:
90
+ """Resolve a path against the root, refusing to escape it."""
91
+ if not rel or rel in (".", "./"):
92
+ return self.root
93
+ candidate = Path(rel)
94
+ if not candidate.is_absolute():
95
+ candidate = self.root / candidate
96
+ candidate = candidate.resolve()
97
+ try:
98
+ candidate.relative_to(self.root)
99
+ except ValueError:
100
+ raise ValueError(
101
+ f"path is outside the project root and is not allowed: {rel}"
102
+ ) from None
103
+ return candidate
104
+
105
+ def relative(self, path: Path) -> str:
106
+ try:
107
+ return str(path.relative_to(self.root)) or "."
108
+ except ValueError:
109
+ return str(path)
110
+
111
+ # -- inspection --------------------------------------------------------
112
+
113
+ def detect_stack(self) -> list[str]:
114
+ """Best-effort detection of languages/frameworks in the project root."""
115
+ stacks: list[str] = []
116
+ has = lambda *names: any((self.root / n).exists() for n in names) # noqa: E731
117
+
118
+ if has("package.json"):
119
+ stacks.append("Node.js")
120
+ stacks.extend(self._detect_js_frameworks())
121
+ if has("composer.json", "artisan"):
122
+ stacks.append("PHP")
123
+ if has("artisan") or self._composer_has("laravel/framework"):
124
+ stacks.append("Laravel")
125
+ if has("pyproject.toml", "requirements.txt", "setup.py"):
126
+ stacks.append("Python")
127
+ if has("go.mod"):
128
+ stacks.append("Go")
129
+ if has("Cargo.toml"):
130
+ stacks.append("Rust")
131
+ if has("pom.xml", "build.gradle", "build.gradle.kts"):
132
+ stacks.append("Java")
133
+ if has("Gemfile"):
134
+ stacks.append("Ruby")
135
+
136
+ db = self._detect_database()
137
+ if db:
138
+ stacks.append(db)
139
+ return stacks
140
+
141
+ def _read_json(self, name: str) -> dict:
142
+ try:
143
+ return json.loads((self.root / name).read_text(encoding="utf-8"))
144
+ except (OSError, json.JSONDecodeError, ValueError):
145
+ return {}
146
+
147
+ def _composer_has(self, package: str) -> bool:
148
+ data = self._read_json("composer.json")
149
+ deps = {**data.get("require", {}), **data.get("require-dev", {})}
150
+ return package in deps
151
+
152
+ def _detect_js_frameworks(self) -> list[str]:
153
+ data = self._read_json("package.json")
154
+ deps = {
155
+ **data.get("dependencies", {}),
156
+ **data.get("devDependencies", {}),
157
+ }
158
+ mapping = [
159
+ ("next", "Next.js"),
160
+ ("react", "React"),
161
+ ("vue", "Vue"),
162
+ ("@angular/core", "Angular"),
163
+ ("svelte", "Svelte"),
164
+ ("express", "Express"),
165
+ ("vite", "Vite"),
166
+ ("typescript", "TypeScript"),
167
+ ]
168
+ return [label for pkg, label in mapping if pkg in deps]
169
+
170
+ def _detect_database(self) -> str | None:
171
+ env = self.root / ".env"
172
+ if not env.exists():
173
+ return None
174
+ try:
175
+ text = env.read_text(encoding="utf-8", errors="replace").lower()
176
+ except OSError:
177
+ return None
178
+ for key, label in (
179
+ ("db_connection=mysql", "MySQL"),
180
+ ("db_connection=pgsql", "PostgreSQL"),
181
+ ("db_connection=postgres", "PostgreSQL"),
182
+ ("db_connection=sqlite", "SQLite"),
183
+ ("db_connection=sqlsrv", "SQL Server"),
184
+ ("mongodb", "MongoDB"),
185
+ ):
186
+ if key in text:
187
+ return label
188
+ return None
189
+
190
+ # -- listing -----------------------------------------------------------
191
+
192
+ def list_tree(
193
+ self,
194
+ rel: str = ".",
195
+ depth: int = 2,
196
+ max_entries: int = 200,
197
+ ) -> str:
198
+ """Return an indented listing of the project tree (read-only)."""
199
+ depth = max(0, int(depth))
200
+ max_entries = max(1, min(int(max_entries), MAX_ENTRIES_LIMIT))
201
+
202
+ try:
203
+ base = self.resolve(rel)
204
+ except ValueError as exc:
205
+ return f"ERROR: {exc}"
206
+
207
+ if not base.exists():
208
+ return (
209
+ f"ERROR: path does not exist: {rel}. "
210
+ "Use list_files to see what actually exists."
211
+ )
212
+ if base.is_file():
213
+ try:
214
+ size = human_size(base.stat().st_size)
215
+ except OSError:
216
+ size = "?"
217
+ return f"{self.relative(base)} ({size})"
218
+
219
+ lines: list[str] = []
220
+ state = {"count": 0, "truncated": False}
221
+
222
+ def walk(directory: Path, prefix: str, remaining_depth: int) -> None:
223
+ if state["truncated"]:
224
+ return
225
+ try:
226
+ entries = sorted(
227
+ directory.iterdir(),
228
+ key=lambda p: (p.is_file(), p.name.lower()),
229
+ )
230
+ except OSError:
231
+ lines.append(f"{prefix}[cannot read directory]")
232
+ return
233
+
234
+ for entry in entries:
235
+ if state["count"] >= max_entries:
236
+ state["truncated"] = True
237
+ return
238
+ if entry.name in DEFAULT_IGNORES:
239
+ continue
240
+ state["count"] += 1
241
+ if entry.is_dir():
242
+ lines.append(f"{prefix}{entry.name}/")
243
+ if remaining_depth > 1:
244
+ walk(entry, prefix + " ", remaining_depth - 1)
245
+ else:
246
+ lines.append(f"{prefix} ...")
247
+ else:
248
+ try:
249
+ size = human_size(entry.stat().st_size)
250
+ except OSError:
251
+ size = "?"
252
+ lines.append(f"{prefix}{entry.name} ({size})")
253
+
254
+ header = f"{self.relative(base)}/"
255
+ walk(base, " ", depth)
256
+
257
+ output = [header, *lines]
258
+ if state["truncated"]:
259
+ output.append(f"... truncated at {max_entries} entries")
260
+ return "\n".join(output)
261
+
262
+ # -- reading -----------------------------------------------------------
263
+
264
+ def read_file(
265
+ self,
266
+ rel: str,
267
+ start_line: int | None = None,
268
+ end_line: int | None = None,
269
+ ) -> str:
270
+ """Return numbered file contents (read-only), or an ERROR string.
271
+
272
+ Guards: confined to the root, refuses directories and binary files, and
273
+ refuses whole-file reads above MAX_READ_BYTES (use a line range instead).
274
+ """
275
+ try:
276
+ path = self.resolve(rel)
277
+ except ValueError as exc:
278
+ return f"ERROR: {exc}"
279
+
280
+ if not path.exists():
281
+ return (
282
+ f"ERROR: file does not exist: {rel}. "
283
+ "Do not guess paths; use list_files to see the project structure "
284
+ "or search_code to locate the file."
285
+ )
286
+ if path.is_dir():
287
+ return f"ERROR: '{rel}' is a directory; use list_files instead"
288
+
289
+ try:
290
+ size = path.stat().st_size
291
+ except OSError as exc:
292
+ return f"ERROR: cannot read '{rel}': {exc}"
293
+
294
+ try:
295
+ start = max(1, int(start_line)) if start_line is not None else 1
296
+ except (TypeError, ValueError):
297
+ return f"ERROR: start_line must be an integer, got {start_line!r}"
298
+ try:
299
+ end = int(end_line) if end_line is not None else None
300
+ except (TypeError, ValueError):
301
+ return f"ERROR: end_line must be an integer, got {end_line!r}"
302
+ if end is not None and end < start:
303
+ return f"ERROR: end_line ({end}) is before start_line ({start})"
304
+
305
+ whole_file = start == 1 and end is None
306
+ if whole_file and size > MAX_READ_BYTES:
307
+ return (
308
+ f"ERROR: '{rel}' is {human_size(size)}, larger than the "
309
+ f"{human_size(MAX_READ_BYTES)} limit. Read a range with "
310
+ "start_line/end_line, or search the file instead."
311
+ )
312
+
313
+ try:
314
+ with path.open("rb") as fh:
315
+ head = fh.read(BINARY_SNIFF_BYTES)
316
+ except OSError as exc:
317
+ return f"ERROR: cannot read '{rel}': {exc}"
318
+ if b"\x00" in head:
319
+ return f"ERROR: '{rel}' looks like a binary file; refusing to read it."
320
+
321
+ selected: list[str] = []
322
+ truncated = False
323
+ try:
324
+ with path.open("r", encoding="utf-8", errors="replace") as fh:
325
+ numbered = enumerate(fh, start=1)
326
+ if start > 1:
327
+ numbered = islice(numbered, start - 1, None)
328
+ for idx, raw in numbered:
329
+ if end is not None and idx > end:
330
+ break
331
+ text = raw.rstrip("\n").rstrip("\r")
332
+ selected.append(f"{idx:>5} | {text}")
333
+ if len(selected) >= MAX_READ_LINES:
334
+ truncated = True
335
+ break
336
+ except OSError as exc:
337
+ return f"ERROR: cannot read '{rel}': {exc}"
338
+
339
+ if not selected:
340
+ return f"{rel} ({human_size(size)}) is empty."
341
+
342
+ first = selected[0].split("|", 1)[0].strip()
343
+ last = selected[-1].split("|", 1)[0].strip()
344
+ header = f"{rel} ({human_size(size)}) [lines {first}-{last}]"
345
+ body = "\n".join(selected)
346
+ if truncated:
347
+ body += f"\n... (truncated at {MAX_READ_LINES} lines)"
348
+ return f"{header}\n{body}"
349
+
350
+ # -- searching ---------------------------------------------------------
351
+
352
+ def _iter_files(self, base: Path) -> Iterator[Path]:
353
+ """Yield every file under `base`, skipping ignored dirs and cycles."""
354
+ if base.is_file():
355
+ yield base
356
+ return
357
+ seen: set[str] = set()
358
+ stack = [base]
359
+ while stack:
360
+ directory = stack.pop()
361
+ try:
362
+ resolved = str(directory.resolve())
363
+ except OSError:
364
+ continue
365
+ if resolved in seen:
366
+ continue
367
+ seen.add(resolved)
368
+ try:
369
+ entries = list(directory.iterdir())
370
+ except OSError:
371
+ continue
372
+ for entry in sorted(entries, key=lambda p: p.name.lower()):
373
+ if entry.name in DEFAULT_IGNORES:
374
+ continue
375
+ if entry.is_dir():
376
+ stack.append(entry)
377
+ elif entry.is_file():
378
+ yield entry
379
+
380
+ @staticmethod
381
+ def _is_binary(path: Path) -> bool:
382
+ try:
383
+ with path.open("rb") as fh:
384
+ return b"\x00" in fh.read(2048)
385
+ except OSError:
386
+ return True
387
+
388
+ @staticmethod
389
+ def _find(query: str, regex: bool, case_sensitive: bool):
390
+ """Return a function mapping a line to the match position, or -1."""
391
+ if regex:
392
+ flags = 0 if case_sensitive else re.IGNORECASE
393
+ pattern = re.compile(query, flags)
394
+
395
+ def find_regex(line: str) -> int:
396
+ match = pattern.search(line)
397
+ return match.start() if match else -1
398
+
399
+ return find_regex
400
+ if case_sensitive:
401
+ return lambda line: line.find(query)
402
+ lowered = query.lower()
403
+ return lambda line: line.lower().find(lowered)
404
+
405
+ @staticmethod
406
+ def _snippet(line: str, pos: int) -> str:
407
+ line = line.rstrip()
408
+ stripped = line.strip()
409
+ if len(stripped) <= SEARCH_LINE_WIDTH:
410
+ return stripped
411
+ start = max(0, pos - 60)
412
+ end = start + SEARCH_LINE_WIDTH
413
+ prefix = "..." if start > 0 else ""
414
+ suffix = "..." if end < len(line) else ""
415
+ return prefix + line[start:end].strip() + suffix
416
+
417
+ def search(
418
+ self,
419
+ query: str,
420
+ rel: str = ".",
421
+ regex: bool = False,
422
+ case_sensitive: bool = False,
423
+ max_results: int = 50,
424
+ ) -> str:
425
+ """Search project files for text, returning `path:line: text` matches."""
426
+ if not query or not str(query).strip():
427
+ return "ERROR: a non-empty query is required"
428
+ query = str(query)
429
+ max_results = max(1, min(int(max_results), MAX_SEARCH_RESULTS))
430
+
431
+ try:
432
+ base = self.resolve(rel)
433
+ except ValueError as exc:
434
+ return f"ERROR: {exc}"
435
+ if not base.exists():
436
+ return f"ERROR: path does not exist: {rel}"
437
+
438
+ try:
439
+ finder = self._find(query, regex, case_sensitive)
440
+ except re.error as exc:
441
+ return f"ERROR: invalid regex {query!r}: {exc}"
442
+
443
+ results: list[str] = []
444
+ skipped_binary = 0
445
+ truncated = False
446
+
447
+ for file_path in self._iter_files(base):
448
+ if len(results) >= max_results:
449
+ truncated = True
450
+ break
451
+ try:
452
+ if file_path.stat().st_size > MAX_SEARCH_FILE_BYTES:
453
+ continue
454
+ except OSError:
455
+ continue
456
+ if self._is_binary(file_path):
457
+ skipped_binary += 1
458
+ continue
459
+ try:
460
+ with file_path.open("r", encoding="utf-8", errors="replace") as fh:
461
+ for line_no, raw in enumerate(fh, start=1):
462
+ pos = finder(raw)
463
+ if pos < 0:
464
+ continue
465
+ snippet = self._snippet(raw, pos)
466
+ results.append(f"{self.relative(file_path)}:{line_no}: {snippet}")
467
+ if len(results) >= max_results:
468
+ truncated = True
469
+ break
470
+ except OSError:
471
+ continue
472
+
473
+ scope = self.relative(base) if base != self.root else "."
474
+ if not results:
475
+ return f"No matches for {query!r} in {scope}."
476
+
477
+ header = f"{len(results)} match(es) for {query!r} in {scope}:"
478
+ output = [header, *results]
479
+ if truncated:
480
+ output.append(f"... truncated at {max_results} matches")
481
+ return "\n".join(output)
482
+
483
+ def summary(self) -> str:
484
+ """One-line description of the project, for the banner / project_info."""
485
+ stacks = self.detect_stack()
486
+ detected = ", ".join(stacks) if stacks else "unknown"
487
+ return f"Project: {self.name}\nRoot: {self.root}\nDetected: {detected}"
package/bin/eljay ADDED
@@ -0,0 +1,5 @@
1
+ #!/usr/bin/env bash
2
+ # Launcher for ElJay AI (Git Bash / WSL / Linux / macOS).
3
+ # `eljay` is the canonical command; `myagent` (in this same bin dir) is a
4
+ # backwards-compatible alias that delegates to the same entry point.
5
+ exec python "$(dirname "$0")/../eljay.py" "$@"
package/bin/eljay.cmd ADDED
@@ -0,0 +1,4 @@
1
+ @echo off
2
+ REM Launcher for ElJay AI (Windows cmd).
3
+ REM `eljay` is the canonical command; `myagent` is a backwards-compatible alias.
4
+ python "%~dp0..\eljay.py" %*
package/bin/myagent ADDED
@@ -0,0 +1,4 @@
1
+ #!/usr/bin/env bash
2
+ # Launcher for ElJay AI (Git Bash / WSL / Linux / macOS).
3
+ # Backwards-compatible alias: delegates to eljay.py.
4
+ exec python "$(dirname "$0")/../eljay.py" "$@"
@@ -0,0 +1,4 @@
1
+ @echo off
2
+ REM Launcher for ElJay AI (Windows cmd).
3
+ REM Backwards-compatible alias: delegates to eljay.py.
4
+ python "%~dp0..\eljay.py" %*
Binary file
package/eljay.js ADDED
@@ -0,0 +1,56 @@
1
+ #!/usr/bin/env node
2
+ 'use strict';
3
+
4
+ /**
5
+ * ElJay AI — npm global install wrapper for the Universal AI Assistant.
6
+ *
7
+ * When installed via `npm i -g eljay-ai`, this file is symlinked into the
8
+ * global bin directory. We resolve the real path to find eljay.py in the
9
+ * package directory, then spawn Python with it.
10
+ *
11
+ * Python 3 must be available on PATH (it is a hard requirement of ElJay AI).
12
+ *
13
+ * Python 3 must be available on PATH (it is a hard requirement of ElJay AI).
14
+ */
15
+
16
+ const { spawnSync } = require('child_process');
17
+ const path = require('path');
18
+ const fs = require('fs');
19
+
20
+ // Resolve the real location of this file (follows npm symlinks).
21
+ const scriptDir = path.dirname(fs.realpathSync(__filename));
22
+ const entryPoint = path.join(scriptDir, 'eljay.py');
23
+
24
+ // Locate a usable Python interpreter.
25
+ function findPython() {
26
+ // On Windows the python command may be the broken Store stub.
27
+ const candidates = ['python', 'python3'];
28
+ for (const candidate of candidates) {
29
+ const check = spawnSync(candidate, ['--version']);
30
+ if (!check.error && check.status === 0) {
31
+ return candidate;
32
+ }
33
+ }
34
+ return null;
35
+ }
36
+
37
+ const python = findPython();
38
+ if (!python) {
39
+ console.error(
40
+ '[error] No Python 3 interpreter found on PATH.\n' +
41
+ ' Install Python 3 from https://python.org and try again.'
42
+ );
43
+ process.exit(1);
44
+ }
45
+
46
+ if (!fs.existsSync(entryPoint)) {
47
+ console.error('[error] eljay.py not found next to eljay.js: ' + entryPoint);
48
+ process.exit(1);
49
+ }
50
+
51
+ // Spawn eljay.py, inheriting stdio so the interactive session works.
52
+ const result = spawnSync(python, [entryPoint, ...process.argv.slice(2)], {
53
+ stdio: 'inherit',
54
+ });
55
+
56
+ process.exit(result.status != null ? result.status : 1);