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,458 @@
1
+ """Tests for ElJay AI universal-assistant components.
2
+
3
+ Run from the project root:
4
+ python tests/test_universal.py
5
+
6
+ These are deterministic and do NOT require Ollama or network access.
7
+ """
8
+
9
+ import os
10
+ import sys
11
+ import unittest
12
+ from pathlib import Path
13
+
14
+ sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
15
+
16
+
17
+ class HardwareTests(unittest.TestCase):
18
+ def test_detect_hardware_returns_hardware_info(self):
19
+ from agent.hardware import detect_hardware
20
+ hw = detect_hardware()
21
+ self.assertIsNotNone(hw)
22
+ self.assertTrue(hasattr(hw, "os"))
23
+ self.assertTrue(hasattr(hw, "cpu_cores"))
24
+ self.assertTrue(hasattr(hw, "ram_gb"))
25
+ self.assertTrue(hasattr(hw, "can_code"))
26
+ self.assertTrue(hasattr(hw, "can_image"))
27
+ self.assertTrue(hasattr(hw, "can_video"))
28
+
29
+ def test_summarize_hardware_returns_string(self):
30
+ from agent.hardware import detect_hardware, summarize_hardware
31
+ hw = detect_hardware()
32
+ s = summarize_hardware(hw)
33
+ self.assertIsInstance(s, str)
34
+ self.assertTrue(len(s) > 0)
35
+
36
+
37
+ class HardwareVramTests(unittest.TestCase):
38
+ def test_hardware_has_vram_available_field(self):
39
+ from agent.hardware import detect_hardware
40
+ hw = detect_hardware()
41
+ self.assertTrue(hasattr(hw, "vram_available_gb"))
42
+ self.assertTrue(hasattr(hw, "vram_total_gb"))
43
+
44
+ def test_summarize_hardware_handles_no_gpu(self):
45
+ from agent.hardware import summarize_hardware, HardwareInfo
46
+ hw = HardwareInfo(gpu="none", vram_total_gb=0.0, vram_available_gb=0.0)
47
+ s = summarize_hardware(hw)
48
+ self.assertIn("none", s.lower())
49
+
50
+ def test_recommended_image_mode_returns_string(self):
51
+ from agent.hardware import HardwareInfo
52
+ hw = HardwareInfo()
53
+ s = hw.recommended_image_mode()
54
+ self.assertIsInstance(s, str)
55
+
56
+ def test_recommended_video_mode_returns_string(self):
57
+ from agent.hardware import HardwareInfo
58
+ hw = HardwareInfo()
59
+ s = hw.recommended_video_mode()
60
+ self.assertIsInstance(s, str)
61
+
62
+
63
+ class RouterTests(unittest.TestCase):
64
+ def test_router_importable(self):
65
+ from agent.router import AgentRouter
66
+ self.assertTrue(callable(AgentRouter))
67
+
68
+ def test_router_can_be_constructed(self):
69
+ from agent.router import AgentRouter
70
+ from agent.skills import Skills
71
+ from agent.knowledge import KnowledgeBase
72
+ root = Path(__file__).resolve().parent.parent
73
+ skills = Skills(str(root))
74
+ knowledge = KnowledgeBase(str(root), str(root))
75
+ router = AgentRouter(skills=skills, knowledge=knowledge)
76
+ self.assertIsNotNone(router)
77
+
78
+ def test_router_len_returns_agent_count(self):
79
+ from agent.router import AgentRouter
80
+ router = AgentRouter()
81
+ self.assertGreater(len(router), 0)
82
+
83
+ def test_router_registry_is_list(self):
84
+ from agent.router import AgentRouter
85
+ router = AgentRouter()
86
+ self.assertIsInstance(router.registry, list)
87
+
88
+ def test_route_returns_agent_definition(self):
89
+ from agent.router import AgentRouter
90
+ from agent.agents import AgentDefinition
91
+ router = AgentRouter()
92
+ # Without provider availability checks, route based on keywords.
93
+ from agent.agents import route_request
94
+ agent = route_request("write some code", None)
95
+ self.assertIsInstance(agent, AgentDefinition)
96
+
97
+ def test_route_image_via_route_request(self):
98
+ from agent.agents import route_request
99
+ agent = route_request("create an image of a cowboy", None)
100
+ self.assertEqual(agent.name, "image")
101
+
102
+ def test_route_video_via_route_request(self):
103
+ from agent.agents import route_request
104
+ agent = route_request("make a cinematic video", None)
105
+ self.assertEqual(agent.name, "video")
106
+
107
+ def test_route_research_via_route_request(self):
108
+ from agent.agents import route_request
109
+ agent = route_request("research the latest Laravel", None)
110
+ self.assertEqual(agent.name, "research")
111
+
112
+ def test_route_coding_via_route_request(self):
113
+ from agent.agents import route_request
114
+ agent = route_request("fix my React error", None)
115
+ self.assertEqual(agent.name, "coding")
116
+
117
+ def test_route_knowledge_via_route_request(self):
118
+ from agent.agents import route_request
119
+ agent = route_request("explain quantum computing", None)
120
+ # Knowledge or main fallback
121
+ self.assertIn(agent.name, ("knowledge", "main"))
122
+
123
+ def test_delegated_agent_for_returns_none_for_main(self):
124
+ from agent.router import AgentRouter
125
+ router = AgentRouter()
126
+ # route_request with provider availability may return main for "hello"
127
+ result = router.delegated_agent_for("hello")
128
+ self.assertTrue(result is None or hasattr(result, "name"))
129
+
130
+ def test_delegated_agent_for_returns_agent_for_specialist(self):
131
+ from agent.router import AgentRouter
132
+ router = AgentRouter()
133
+ # route_request with None providers bypasses availability check
134
+ from agent.agents import route_request
135
+ agent = route_request("create an image", None)
136
+ if agent.name == "image":
137
+ self.assertEqual(agent.name, "image")
138
+ else:
139
+ # If image provider unavailable, router.route may return main.
140
+ # But route_request with None should still match image.
141
+ self.assertEqual(agent.name, "image")
142
+
143
+ def test_agent_by_name(self):
144
+ from agent.router import AgentRouter
145
+ router = AgentRouter()
146
+ agent = router.agent_by_name("image")
147
+ self.assertIsNotNone(agent)
148
+ self.assertEqual(agent.name, "image")
149
+
150
+ def test_all_agents_returns_list(self):
151
+ from agent.router import AgentRouter
152
+ router = AgentRouter()
153
+ agents = router.all_agents()
154
+ self.assertIsInstance(agents, list)
155
+ self.assertGreater(len(agents), 0)
156
+
157
+ def test_provider_status_returns_dict(self):
158
+ from agent.router import AgentRouter
159
+ router = AgentRouter()
160
+ status = router.provider_status()
161
+ self.assertIsInstance(status, dict)
162
+
163
+ def test_inject_context_returns_string(self):
164
+ from agent.router import AgentRouter
165
+ router = AgentRouter()
166
+ from agent.agents import route_request
167
+ agent = route_request("coding help", None)
168
+ ctx = router.inject_context("coding help", agent)
169
+ self.assertIsInstance(ctx, str)
170
+
171
+ def test_route_falls_back_to_main_for_greeting(self):
172
+ from agent.agents import route_request
173
+ agent = route_request("hello world", None)
174
+ self.assertEqual(agent.name, "main")
175
+
176
+ def test_route_image_via_router_even_without_provider(self):
177
+ from agent.router import AgentRouter
178
+ router = AgentRouter()
179
+ agent = router.route("create an image of a cyberpunk city")
180
+ self.assertEqual(agent.name, "image")
181
+
182
+ def test_route_video_via_router_even_without_provider(self):
183
+ from agent.router import AgentRouter
184
+ router = AgentRouter()
185
+ agent = router.route("make a cinematic video")
186
+ self.assertEqual(agent.name, "video")
187
+
188
+
189
+ class SkillsTests(unittest.TestCase):
190
+ def test_skills_importable(self):
191
+ from agent.skills import Skills
192
+ self.assertTrue(callable(Skills))
193
+
194
+ def test_skills_can_be_constructed(self):
195
+ from agent.skills import Skills
196
+ root = Path(__file__).resolve().parent.parent
197
+ skills = Skills(str(root))
198
+ self.assertIsNotNone(skills)
199
+
200
+ def test_skills_all_skills_returns_list(self):
201
+ from agent.skills import Skills
202
+ root = Path(__file__).resolve().parent.parent
203
+ skills = Skills(str(root))
204
+ self.assertIsInstance(skills.all_skills(), list)
205
+
206
+ def test_skills_has_summary(self):
207
+ from agent.skills import Skills
208
+ root = Path(__file__).resolve().parent.parent
209
+ skills = Skills(str(root))
210
+ s = skills.summary()
211
+ self.assertIsInstance(s, str)
212
+
213
+ def test_skills_relevant_to(self):
214
+ from agent.skills import Skills
215
+ root = Path(__file__).resolve().parent.parent
216
+ skills = Skills(str(root))
217
+ results = skills.relevant_to("debug")
218
+ self.assertIsInstance(results, list)
219
+
220
+ def test_skills_search(self):
221
+ from agent.skills import Skills
222
+ root = Path(__file__).resolve().parent.parent
223
+ skills = Skills(str(root))
224
+ results = skills.search("coding")
225
+ self.assertIsInstance(results, list)
226
+
227
+ def test_skills_directory_exists(self):
228
+ root = Path(__file__).resolve().parent.parent / "skills"
229
+ self.assertTrue(root.exists(), "skills/ directory should exist")
230
+
231
+ def test_skills_directory_has_content(self):
232
+ root = Path(__file__).resolve().parent.parent / "skills"
233
+ skills = list(root.rglob("*.md"))
234
+ self.assertGreater(len(skills), 0, "skills/ should contain .md files")
235
+
236
+
237
+ class KnowledgeTests(unittest.TestCase):
238
+ def test_knowledge_importable(self):
239
+ from agent.knowledge import KnowledgeBase
240
+ self.assertTrue(callable(KnowledgeBase))
241
+
242
+ def test_knowledge_can_be_constructed(self):
243
+ from agent.knowledge import KnowledgeBase
244
+ root = str(Path(__file__).resolve().parent.parent)
245
+ kb = KnowledgeBase(root, root)
246
+ self.assertIsNotNone(kb)
247
+
248
+ def test_knowledge_load_global(self):
249
+ from agent.knowledge import KnowledgeBase
250
+ root = str(Path(__file__).resolve().parent.parent)
251
+ kb = KnowledgeBase(root, root)
252
+ entries = kb.entries()
253
+ self.assertIsInstance(entries, list)
254
+
255
+ def test_knowledge_has_summary(self):
256
+ from agent.knowledge import KnowledgeBase
257
+ root = str(Path(__file__).resolve().parent.parent)
258
+ kb = KnowledgeBase(root, root)
259
+ s = kb.summary()
260
+ self.assertIsInstance(s, str)
261
+
262
+ def test_knowledge_search_returns_list(self):
263
+ from agent.knowledge import KnowledgeBase
264
+ root = str(Path(__file__).resolve().parent.parent)
265
+ kb = KnowledgeBase(root, root)
266
+ results = kb.search("image")
267
+ self.assertIsInstance(results, list)
268
+
269
+ def test_knowledge_directory_exists(self):
270
+ root = Path(__file__).resolve().parent.parent / "knowledge"
271
+ self.assertTrue(root.exists(), "knowledge/ directory should exist")
272
+
273
+ def test_knowledge_add_and_search(self):
274
+ from agent.knowledge import KnowledgeBase
275
+ root = str(Path(__file__).resolve().parent.parent)
276
+ kb = KnowledgeBase(root, root)
277
+ kb.add_entry("test-category", "test-entry", "a test entry about Python", "Python is a programming language", project_local=False)
278
+ results = kb.search("Python")
279
+ self.assertGreater(len(results), 0)
280
+
281
+
282
+ class AgentsRegistryTests(unittest.TestCase):
283
+ def test_list_agents_returns_all(self):
284
+ from agent.agents import list_agents
285
+ agents = list_agents()
286
+ self.assertGreaterEqual(len(agents), 6, "Should have at least 6 agent types")
287
+
288
+ def test_main_agent_present(self):
289
+ from agent.agents import get_agent
290
+ agent = get_agent("main")
291
+ self.assertIsNotNone(agent)
292
+ self.assertEqual(agent.name, "main")
293
+
294
+ def test_coding_agent_present(self):
295
+ from agent.agents import get_agent
296
+ agent = get_agent("coding")
297
+ self.assertIsNotNone(agent)
298
+ self.assertEqual(agent.name, "coding")
299
+
300
+ def test_image_agent_present(self):
301
+ from agent.agents import get_agent
302
+ agent = get_agent("image")
303
+ self.assertIsNotNone(agent)
304
+ self.assertEqual(agent.name, "image")
305
+
306
+ def test_video_agent_present(self):
307
+ from agent.agents import get_agent
308
+ agent = get_agent("video")
309
+ self.assertIsNotNone(agent)
310
+ self.assertEqual(agent.name, "video")
311
+
312
+ def test_research_agent_present(self):
313
+ from agent.agents import get_agent
314
+ agent = get_agent("research")
315
+ self.assertIsNotNone(agent)
316
+ self.assertEqual(agent.name, "research")
317
+
318
+ def test_knowledge_agent_present(self):
319
+ from agent.agents import get_agent
320
+ agent = get_agent("knowledge")
321
+ self.assertIsNotNone(agent)
322
+ self.assertEqual(agent.name, "knowledge")
323
+
324
+ def test_route_request_returns_agent(self):
325
+ from agent.agents import route_request
326
+ agent = route_request("create an image", None)
327
+ self.assertEqual(agent.name, "image")
328
+
329
+ def test_route_request_fallback_to_main(self):
330
+ from agent.agents import route_request
331
+ agent = route_request("hello world", None)
332
+ self.assertEqual(agent.name, "main")
333
+
334
+ def test_route_request_coding_keywords(self):
335
+ from agent.agents import route_request
336
+ agent = route_request("fix my code", None)
337
+ self.assertEqual(agent.name, "coding")
338
+
339
+
340
+ class ProvidersTests(unittest.TestCase):
341
+ def test_providers_importable(self):
342
+ from agent.providers import PROVIDERS
343
+ self.assertIsInstance(PROVIDERS, list)
344
+
345
+ def test_providers_has_required_types(self):
346
+ from agent.providers import PROVIDERS
347
+ names = [p.name for p in PROVIDERS]
348
+ self.assertIn("ollama", names)
349
+
350
+ def test_get_provider_capable_of_returns_provider(self):
351
+ from agent.providers import get_provider_capable_of
352
+ provider = get_provider_capable_of("text")
353
+ self.assertIsNotNone(provider)
354
+
355
+ def test_get_provider_capable_of_returns_none_for_missing(self):
356
+ from agent.providers import get_provider_capable_of
357
+ provider = get_provider_capable_of("nonexistent-capability")
358
+ self.assertIsNone(provider)
359
+
360
+ def test_image_provider_importable(self):
361
+ from agent.providers.image import ImageProvider
362
+ self.assertTrue(hasattr(ImageProvider, "status_string"))
363
+
364
+ def test_video_provider_importable(self):
365
+ from agent.providers.video import VideoProvider
366
+ self.assertTrue(hasattr(VideoProvider, "status_string"))
367
+
368
+ def test_web_search_provider_importable(self):
369
+ from agent.providers.web import WebSearchProvider
370
+ self.assertTrue(hasattr(WebSearchProvider, "status_string"))
371
+
372
+ def test_search_ddg_function_exists(self):
373
+ from agent.providers.web import search_ddg
374
+ self.assertTrue(callable(search_ddg))
375
+
376
+ def test_fetch_web_page_function_exists(self):
377
+ from agent.providers.web import fetch_web_page
378
+ self.assertTrue(callable(fetch_web_page))
379
+
380
+
381
+ class ToolRegistryTests(unittest.TestCase):
382
+ def test_register_builtin_tools_callable(self):
383
+ from agent.builtin_tools import register_builtin_tools
384
+ self.assertTrue(callable(register_builtin_tools))
385
+
386
+ def test_tool_registry_has_tools(self):
387
+ from agent.tools import ToolRegistry
388
+ from agent.builtin_tools import register_builtin_tools
389
+ from agent.workspace import Workspace, find_project_root
390
+ from agent.memory import Memory
391
+ from pathlib import Path
392
+ ws = Workspace(find_project_root(Path.cwd()))
393
+ mem = Memory(ws)
394
+ registry = ToolRegistry()
395
+ register_builtin_tools(registry, ws, mem)
396
+ self.assertGreater(len(registry), 0)
397
+
398
+
399
+ class CLITests(unittest.TestCase):
400
+ def test_eljay_has_main(self):
401
+ import eljay
402
+ self.assertTrue(callable(eljay.main))
403
+
404
+ def test_myagent_is_alias(self):
405
+ import myagent
406
+ self.assertTrue(callable(myagent.main))
407
+
408
+ def test_build_registry_callable(self):
409
+ import eljay
410
+ self.assertTrue(callable(eljay.build_registry))
411
+
412
+ def test_resolve_model_callable(self):
413
+ import eljay
414
+ self.assertTrue(callable(eljay.resolve_model))
415
+
416
+
417
+ class OutputDirectoryTests(unittest.TestCase):
418
+ def test_agent_outputs_dir_exists_or_can_create(self):
419
+ """Ensure the .agent/outputs directory structure can be created."""
420
+ from pathlib import Path
421
+ root = Path(__file__).resolve().parent.parent
422
+ outputs = root / ".agent" / "outputs"
423
+ images = outputs / "images"
424
+ videos = outputs / "videos"
425
+ images.mkdir(parents=True, exist_ok=True)
426
+ videos.mkdir(parents=True, exist_ok=True)
427
+ self.assertTrue(images.exists())
428
+ self.assertTrue(videos.exists())
429
+
430
+
431
+ class ProviderFactoryTests(unittest.TestCase):
432
+ """Verify all four provider classes can be instantiated."""
433
+
434
+ def test_ollama_provider_instantiable(self):
435
+ from agent.providers.ollama import OllamaProvider
436
+ p = OllamaProvider()
437
+ self.assertEqual(p.name, "ollama")
438
+ self.assertIn("text", p.capabilities)
439
+
440
+ def test_web_search_provider_instantiable(self):
441
+ from agent.providers.web import WebSearchProvider
442
+ p = WebSearchProvider()
443
+ self.assertEqual(p.name, "duckduckgo")
444
+ self.assertIn("web-search", p.capabilities)
445
+
446
+ def test_image_provider_instantiable(self):
447
+ from agent.providers.image import ImageProvider
448
+ p = ImageProvider()
449
+ self.assertEqual(p.name, "image-local")
450
+
451
+ def test_video_provider_instantiable(self):
452
+ from agent.providers.video import VideoProvider
453
+ p = VideoProvider()
454
+ self.assertEqual(p.name, "video-local")
455
+
456
+
457
+ if __name__ == "__main__":
458
+ unittest.main(verbosity=2)
@@ -0,0 +1,221 @@
1
+ """Deterministic tests for the workspace module — no model, no network.
2
+
3
+ Run from the project root:
4
+ python tests/test_workspace.py
5
+ """
6
+
7
+ import json
8
+ import os
9
+ import sys
10
+ import tempfile
11
+ import unittest
12
+ from pathlib import Path
13
+
14
+ sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
15
+
16
+ from agent.workspace import ( # noqa: E402
17
+ Workspace,
18
+ find_project_root,
19
+ human_size,
20
+ )
21
+
22
+
23
+ class WorkspaceTests(unittest.TestCase):
24
+ def setUp(self):
25
+ self._tmp = tempfile.TemporaryDirectory()
26
+ self.root = Path(self._tmp.name)
27
+ (self.root / "a.py").write_text("print('hi')\n", encoding="utf-8")
28
+ (self.root / "sub").mkdir()
29
+ (self.root / "sub" / "b.txt").write_text("hello", encoding="utf-8")
30
+ (self.root / "node_modules").mkdir()
31
+ (self.root / "node_modules" / "x.js").write_text("ignore me", encoding="utf-8")
32
+ self.ws = Workspace(self.root)
33
+
34
+ def tearDown(self):
35
+ self._tmp.cleanup()
36
+
37
+ # -- safety ------------------------------------------------------------
38
+
39
+ def test_resolve_allows_paths_inside_root(self):
40
+ self.assertEqual(self.ws.resolve("sub"), (self.root / "sub").resolve())
41
+
42
+ def test_resolve_rejects_escape_attempts(self):
43
+ with self.assertRaises(ValueError):
44
+ self.ws.resolve("..")
45
+ with self.assertRaises(ValueError):
46
+ self.ws.resolve("../../etc/passwd")
47
+
48
+ def test_resolve_rejects_absolute_path_outside_root(self):
49
+ with self.assertRaises(ValueError):
50
+ self.ws.resolve(str(self.root.parent))
51
+
52
+ # -- listing -----------------------------------------------------------
53
+
54
+ def test_list_tree_shows_files_and_dirs(self):
55
+ out = self.ws.list_tree(depth=3)
56
+ self.assertIn("a.py", out)
57
+ self.assertIn("sub/", out)
58
+ self.assertIn("b.txt", out)
59
+
60
+ def test_list_tree_skips_ignored_dirs(self):
61
+ out = self.ws.list_tree(depth=3)
62
+ self.assertNotIn("node_modules", out)
63
+ self.assertNotIn("x.js", out)
64
+
65
+ def test_list_tree_respects_depth(self):
66
+ out = self.ws.list_tree(depth=1)
67
+ self.assertIn("sub/", out)
68
+ self.assertNotIn("b.txt", out) # one level down should be hidden
69
+
70
+ def test_list_tree_truncates(self):
71
+ for i in range(20):
72
+ (self.root / f"file_{i}.txt").write_text("x", encoding="utf-8")
73
+ out = self.ws.list_tree(depth=2, max_entries=5)
74
+ self.assertIn("truncated", out)
75
+
76
+ def test_list_tree_missing_path_reports_error(self):
77
+ out = self.ws.list_tree(rel="does_not_exist")
78
+ self.assertIn("does not exist", out)
79
+
80
+ def test_list_tree_outside_root_reports_error(self):
81
+ out = self.ws.list_tree(rel="../..")
82
+ self.assertIn("ERROR", out)
83
+
84
+ # -- detection ---------------------------------------------------------
85
+
86
+ def test_find_project_root_from_nested_dir(self):
87
+ (self.root / ".git").mkdir()
88
+ nested = self.root / "deep" / "deeper"
89
+ nested.mkdir(parents=True)
90
+ self.assertEqual(find_project_root(nested), self.root.resolve())
91
+
92
+ def test_detect_stack_react(self):
93
+ (self.root / "package.json").write_text(
94
+ json.dumps({"dependencies": {"react": "^18.0.0"}}), encoding="utf-8"
95
+ )
96
+ stacks = self.ws.detect_stack()
97
+ self.assertIn("Node.js", stacks)
98
+ self.assertIn("React", stacks)
99
+
100
+ def test_detect_stack_laravel(self):
101
+ (self.root / "artisan").write_text("", encoding="utf-8")
102
+ (self.root / "composer.json").write_text(
103
+ json.dumps({"require": {"laravel/framework": "^11.0"}}), encoding="utf-8"
104
+ )
105
+ stacks = self.ws.detect_stack()
106
+ self.assertIn("PHP", stacks)
107
+ self.assertIn("Laravel", stacks)
108
+
109
+ def test_detect_stack_database_from_env(self):
110
+ (self.root / ".env").write_text("DB_CONNECTION=mysql\n", encoding="utf-8")
111
+ self.assertIn("MySQL", self.ws.detect_stack())
112
+
113
+ # -- reading -----------------------------------------------------------
114
+
115
+ def test_read_file_returns_numbered_lines(self):
116
+ out = self.ws.read_file("sub/b.txt")
117
+ self.assertIn("hello", out)
118
+ self.assertIn("1 |", out)
119
+
120
+ def test_read_file_line_range(self):
121
+ (self.root / "nums.txt").write_text(
122
+ "\n".join(f"line{i}" for i in range(1, 11)) + "\n", encoding="utf-8"
123
+ )
124
+ out = self.ws.read_file("nums.txt", start_line=3, end_line=5)
125
+ self.assertIn("line3", out)
126
+ self.assertIn("line5", out)
127
+ self.assertNotIn("line2", out)
128
+ self.assertNotIn("line6", out)
129
+
130
+ def test_read_file_refuses_directory(self):
131
+ out = self.ws.read_file("sub")
132
+ self.assertIn("is a directory", out)
133
+
134
+ def test_read_file_refuses_binary(self):
135
+ (self.root / "blob.bin").write_bytes(b"PNG\x00\x01\x02binary")
136
+ out = self.ws.read_file("blob.bin")
137
+ self.assertIn("binary", out.lower())
138
+
139
+ def test_read_file_refuses_large_file_without_range(self):
140
+ (self.root / "big.txt").write_text("a" * 1_100_000, encoding="utf-8")
141
+ out = self.ws.read_file("big.txt")
142
+ self.assertIn("larger than", out)
143
+
144
+ def test_read_file_large_file_with_range_is_allowed(self):
145
+ # >1 MB file made of many short lines, so a small range stays small.
146
+ (self.root / "big2.txt").write_text("x\n" * 600_000, encoding="utf-8")
147
+ out = self.ws.read_file("big2.txt", start_line=1, end_line=2)
148
+ self.assertNotIn("larger than", out)
149
+ self.assertIn("1 |", out)
150
+
151
+ def test_read_file_outside_root_is_rejected(self):
152
+ out = self.ws.read_file("../../secret.txt")
153
+ self.assertIn("ERROR", out)
154
+
155
+ def test_read_file_missing_reports_error(self):
156
+ out = self.ws.read_file("nope.txt")
157
+ self.assertIn("does not exist", out)
158
+
159
+ def test_read_file_reversed_range_reports_error(self):
160
+ out = self.ws.read_file("a.py", start_line=5, end_line=2)
161
+ self.assertIn("ERROR", out)
162
+
163
+ # -- searching ---------------------------------------------------------
164
+
165
+ def test_search_finds_literal_with_line_number(self):
166
+ (self.root / "code.py").write_text(
167
+ "x = 1\ndef useAuth():\n pass\n", encoding="utf-8"
168
+ )
169
+ out = self.ws.search("useAuth")
170
+ self.assertIn("code.py:2:", out)
171
+ self.assertIn("def useAuth", out)
172
+
173
+ def test_search_is_case_insensitive_by_default(self):
174
+ (self.root / "code.py").write_text("UseAuth()\n", encoding="utf-8")
175
+ self.assertIn("code.py", self.ws.search("useauth"))
176
+
177
+ def test_search_case_sensitive(self):
178
+ (self.root / "code.py").write_text("UseAuth()\n", encoding="utf-8")
179
+ self.assertIn("No matches", self.ws.search("useauth", case_sensitive=True))
180
+
181
+ def test_search_regex(self):
182
+ (self.root / "code.py").write_text("foo123\nbar\n", encoding="utf-8")
183
+ self.assertIn("foo123", self.ws.search(r"foo\d+", regex=True))
184
+
185
+ def test_search_invalid_regex_reports_error(self):
186
+ self.assertIn("ERROR", self.ws.search("([", regex=True))
187
+
188
+ def test_search_empty_query_reports_error(self):
189
+ self.assertIn("ERROR", self.ws.search(""))
190
+
191
+ def test_search_missing_path_reports_error(self):
192
+ self.assertIn("ERROR", self.ws.search("x", rel="nope"))
193
+
194
+ def test_search_skips_ignored_dirs(self):
195
+ (self.root / "node_modules" / "x.js").write_text("needle\n", encoding="utf-8")
196
+ self.assertIn("No matches", self.ws.search("needle"))
197
+
198
+ def test_search_skips_binary_files(self):
199
+ (self.root / "blob.bin").write_bytes(b"\x00needle")
200
+ self.assertIn("No matches", self.ws.search("needle"))
201
+
202
+ def test_search_truncates_at_max_results(self):
203
+ (self.root / "many.txt").write_text("hit\n" * 20, encoding="utf-8")
204
+ out = self.ws.search("hit", max_results=5)
205
+ self.assertIn("truncated", out)
206
+
207
+ def test_search_scoped_to_subdir_excludes_elsewhere(self):
208
+ (self.root / "a.py").write_text("needle\n", encoding="utf-8")
209
+ (self.root / "sub" / "b.txt").write_text("needle\n", encoding="utf-8")
210
+ out = self.ws.search("needle", rel="sub")
211
+ self.assertIn("b.txt", out)
212
+ self.assertNotIn("a.py", out)
213
+
214
+ def test_human_size(self):
215
+ self.assertEqual(human_size(0), "0 B")
216
+ self.assertEqual(human_size(512), "512 B")
217
+ self.assertEqual(human_size(2048), "2.0 KB")
218
+
219
+
220
+ if __name__ == "__main__":
221
+ unittest.main(verbosity=2)