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.
- package/AGENTS.md +57 -0
- package/README.md +479 -0
- package/agent/__init__.py +10 -0
- package/agent/agent.py +269 -0
- package/agent/agents/__init__.py +34 -0
- package/agent/agents/registry.py +212 -0
- package/agent/builtin_tools.py +591 -0
- package/agent/chat.py +354 -0
- package/agent/config.py +67 -0
- package/agent/context.py +63 -0
- package/agent/edits.py +167 -0
- package/agent/gitaware.py +126 -0
- package/agent/hardware.py +331 -0
- package/agent/knowledge.py +133 -0
- package/agent/memory.py +98 -0
- package/agent/ollama_client.py +133 -0
- package/agent/permissions.py +93 -0
- package/agent/providers/__init__.py +51 -0
- package/agent/providers/base.py +78 -0
- package/agent/providers/image.py +110 -0
- package/agent/providers/ollama.py +111 -0
- package/agent/providers/video.py +96 -0
- package/agent/providers/web.py +155 -0
- package/agent/router.py +200 -0
- package/agent/rules.py +52 -0
- package/agent/runner.py +168 -0
- package/agent/skills.py +161 -0
- package/agent/tools.py +102 -0
- package/agent/verification.py +80 -0
- package/agent/workspace.py +487 -0
- package/bin/eljay +5 -0
- package/bin/eljay.cmd +4 -0
- package/bin/myagent +4 -0
- package/bin/myagent.cmd +4 -0
- package/eljay-ai-1.1.0.tgz +0 -0
- package/eljay.js +56 -0
- package/eljay.py +150 -0
- package/install.ps1 +28 -0
- package/knowledge/reference/diffusers.md +32 -0
- package/knowledge/reference/video-providers.md +21 -0
- package/knowledge/setup/comfyui.md +35 -0
- package/knowledge/setup/image-providers.md +16 -0
- package/knowledge/test-category/test-entry.md +5 -0
- package/myagent.py +30 -0
- package/package.json +40 -0
- package/skills/coding/code-review.md +3 -0
- package/skills/coding/fix-attempt-protocol.md +9 -0
- package/skills/debugging/root-cause-analysis.md +9 -0
- package/skills/general/communication.md +7 -0
- package/skills/laravel/authentication.md +15 -0
- package/skills/mysql/performance.md +9 -0
- package/skills/php/standards.md +8 -0
- package/skills/react/component-best-practices.md +8 -0
- package/skills/research/source-tracking.md +7 -0
- package/skills/security/input-validation.md +9 -0
- package/skills/testing/pytest-best-practices.md +7 -0
- package/tests/run_all.py +34 -0
- package/tests/test_agent_core.py +385 -0
- package/tests/test_capabilities.py +169 -0
- package/tests/test_edits.py +117 -0
- package/tests/test_eljay.py +209 -0
- package/tests/test_runner.py +114 -0
- package/tests/test_universal.py +458 -0
- 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
package/bin/myagent
ADDED
package/bin/myagent.cmd
ADDED
|
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);
|