lemming-cli 0.1.0__py3-none-any.whl

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 (94) hide show
  1. lemming/__init__.py +1 -0
  2. lemming/api/__init__.py +5 -0
  3. lemming/api/auth.py +34 -0
  4. lemming/api/auth_test.py +40 -0
  5. lemming/api/config.py +85 -0
  6. lemming/api/config_test.py +84 -0
  7. lemming/api/conftest.py +204 -0
  8. lemming/api/context.py +39 -0
  9. lemming/api/context_test.py +62 -0
  10. lemming/api/directories.py +57 -0
  11. lemming/api/directories_test.py +94 -0
  12. lemming/api/files.py +116 -0
  13. lemming/api/files_test.py +163 -0
  14. lemming/api/hooks.py +15 -0
  15. lemming/api/hooks_test.py +33 -0
  16. lemming/api/logging.py +16 -0
  17. lemming/api/logging_test.py +59 -0
  18. lemming/api/loop.py +45 -0
  19. lemming/api/loop_test.py +58 -0
  20. lemming/api/main.py +53 -0
  21. lemming/api/main_test.py +30 -0
  22. lemming/api/tasks.py +170 -0
  23. lemming/api/tasks_test.py +426 -0
  24. lemming/api.py +5 -0
  25. lemming/api_test.py +7 -0
  26. lemming/cli/__init__.py +12 -0
  27. lemming/cli/config.py +67 -0
  28. lemming/cli/config_test.py +50 -0
  29. lemming/cli/context.py +40 -0
  30. lemming/cli/context_test.py +49 -0
  31. lemming/cli/hooks.py +147 -0
  32. lemming/cli/hooks_test.py +50 -0
  33. lemming/cli/main.py +42 -0
  34. lemming/cli/main_test.py +21 -0
  35. lemming/cli/operations.py +226 -0
  36. lemming/cli/operations_test.py +44 -0
  37. lemming/cli/progress.py +54 -0
  38. lemming/cli/progress_test.py +40 -0
  39. lemming/cli/readability_cli.py +28 -0
  40. lemming/cli/readability_cli_test.py +57 -0
  41. lemming/cli/tasks.py +529 -0
  42. lemming/cli/tasks_test.py +168 -0
  43. lemming/cli.py +5 -0
  44. lemming/cli_test.py +22 -0
  45. lemming/conftest.py +13 -0
  46. lemming/hooks.py +145 -0
  47. lemming/hooks_test.py +180 -0
  48. lemming/integration_test.py +299 -0
  49. lemming/main.py +6 -0
  50. lemming/main_test.py +44 -0
  51. lemming/models.py +88 -0
  52. lemming/models_test.py +91 -0
  53. lemming/orchestrator.py +407 -0
  54. lemming/orchestrator_test.py +468 -0
  55. lemming/paths.py +245 -0
  56. lemming/paths_test.py +186 -0
  57. lemming/persistence.py +179 -0
  58. lemming/persistence_test.py +150 -0
  59. lemming/prompts/hooks/readability.md +42 -0
  60. lemming/prompts/hooks/roadmap.md +61 -0
  61. lemming/prompts/hooks/testing.md +44 -0
  62. lemming/prompts/taskrunner.md +69 -0
  63. lemming/prompts.py +354 -0
  64. lemming/prompts_test.py +421 -0
  65. lemming/providers.py +190 -0
  66. lemming/providers_test.py +73 -0
  67. lemming/runner.py +327 -0
  68. lemming/runner_test.py +378 -0
  69. lemming/tasks/__init__.py +75 -0
  70. lemming/tasks/lifecycle.py +331 -0
  71. lemming/tasks/lifecycle_test.py +312 -0
  72. lemming/tasks/operations.py +258 -0
  73. lemming/tasks/operations_test.py +172 -0
  74. lemming/tasks/progress.py +29 -0
  75. lemming/tasks/progress_test.py +22 -0
  76. lemming/tasks/queries.py +128 -0
  77. lemming/tasks/queries_test.py +233 -0
  78. lemming/web/dashboard.spec.js +350 -0
  79. lemming/web/dashboard.test.js +998 -0
  80. lemming/web/favicon.js +40 -0
  81. lemming/web/favicon.spec.js +242 -0
  82. lemming/web/files.html +375 -0
  83. lemming/web/files.spec.js +97 -0
  84. lemming/web/index.html +983 -0
  85. lemming/web/index.js +753 -0
  86. lemming/web/logs.html +358 -0
  87. lemming/web/logs.test.js +195 -0
  88. lemming/web/mancha.js +58 -0
  89. lemming/web/screenshots.spec.js +328 -0
  90. lemming_cli-0.1.0.dist-info/METADATA +314 -0
  91. lemming_cli-0.1.0.dist-info/RECORD +94 -0
  92. lemming_cli-0.1.0.dist-info/WHEEL +4 -0
  93. lemming_cli-0.1.0.dist-info/entry_points.txt +2 -0
  94. lemming_cli-0.1.0.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,468 @@
1
+ import time
2
+ from unittest import mock
3
+
4
+ import pytest
5
+
6
+ from lemming import tasks
7
+ from lemming.orchestrator import (
8
+ _handle_runner_exit,
9
+ _process_exhausted_retries,
10
+ _process_finalizing_task,
11
+ format_duration,
12
+ parse_timeout,
13
+ run_loop,
14
+ )
15
+ from lemming.runner import RETURNCODE_TIMEOUT
16
+
17
+
18
+ @pytest.fixture
19
+ def setup_env(tmp_path):
20
+ test_tasks_file = tmp_path / "tasks_test.yml"
21
+
22
+ # Scaffold a valid file with one task
23
+ initial_data = tasks.Roadmap(
24
+ context="Initial context",
25
+ tasks=[
26
+ tasks.Task(
27
+ id="task1",
28
+ description="Task 1",
29
+ status=tasks.TaskStatus.PENDING,
30
+ attempts=0,
31
+ progress=[],
32
+ )
33
+ ],
34
+ config=tasks.RoadmapConfig(retries=3, runner="agy"),
35
+ )
36
+ tasks.save_tasks(test_tasks_file, initial_data)
37
+ return test_tasks_file, initial_data
38
+
39
+
40
+ def test_parse_timeout():
41
+ assert parse_timeout("0") == 0.0
42
+ assert parse_timeout("-1h") == 0.0
43
+ assert parse_timeout("8h") == 8 * 3600.0
44
+ assert parse_timeout("30m") == 30 * 60.0
45
+ assert parse_timeout("90s") == 90.0
46
+ assert parse_timeout("invalid") == 0.0
47
+
48
+
49
+ def test_format_duration():
50
+ assert format_duration(0) == "none"
51
+ assert format_duration(-1) == "none"
52
+ assert format_duration(30) == "30m"
53
+ assert format_duration(60) == "1h"
54
+ assert format_duration(90) == "90m"
55
+ assert format_duration(120) == "2h"
56
+
57
+
58
+ @mock.patch("subprocess.Popen")
59
+ def test_run_loop_success(mock_popen, setup_env):
60
+ test_tasks_file, initial_data = setup_env
61
+ # Simulate runner reporting success
62
+ mock_process = mock.MagicMock()
63
+ mock_process.pid = 12345
64
+ mock_process.poll.side_effect = [None, 0]
65
+ mock_process.returncode = 0
66
+ mock_process.stdout = iter(["stdout\n"])
67
+ mock_process.communicate.return_value = ("stdout", "stderr")
68
+
69
+ def wait_side_effect():
70
+ with tasks.lock_tasks(test_tasks_file):
71
+ data = tasks.load_tasks(test_tasks_file)
72
+ data.tasks[0].status = tasks.TaskStatus.COMPLETED
73
+ data.tasks[0].completed_at = time.time()
74
+ tasks.save_tasks(test_tasks_file, data)
75
+ return 0
76
+
77
+ mock_process.wait.side_effect = wait_side_effect
78
+ mock_popen.return_value = mock_process
79
+
80
+ run_loop(
81
+ test_tasks_file,
82
+ verbose=True,
83
+ retry_delay=0,
84
+ yolo=True,
85
+ no_defaults=False,
86
+ runner_args=(),
87
+ )
88
+
89
+ data = tasks.load_tasks(test_tasks_file)
90
+ assert data.tasks[0].status == tasks.TaskStatus.COMPLETED
91
+
92
+
93
+ @mock.patch("subprocess.Popen")
94
+ @mock.patch("time.sleep", return_value=None)
95
+ def test_run_loop_retry_and_fail(mock_sleep, mock_popen, setup_env):
96
+ test_tasks_file, initial_data = setup_env
97
+ # Runner finishes but doesn't report completion
98
+ mock_process = mock.MagicMock()
99
+ mock_process.pid = 12345
100
+ mock_process.poll.return_value = 0
101
+ mock_process.returncode = 0
102
+ mock_process.stdout = iter(["stdout\n"])
103
+ mock_process.communicate.return_value = ("stdout", "stderr")
104
+ mock_popen.return_value = mock_process
105
+
106
+ # Configure 2 retries
107
+ data = tasks.load_tasks(test_tasks_file)
108
+ data.config.retries = 2
109
+ tasks.save_tasks(test_tasks_file, data)
110
+
111
+ run_loop(
112
+ test_tasks_file,
113
+ verbose=True,
114
+ retry_delay=0,
115
+ yolo=True,
116
+ no_defaults=False,
117
+ runner_args=(),
118
+ )
119
+
120
+ data = tasks.load_tasks(test_tasks_file)
121
+ assert data.tasks[0].attempts == 2
122
+ assert data.tasks[0].status == tasks.TaskStatus.FAILED
123
+
124
+
125
+ def test_synchronous_hooks_execution_timing(setup_env):
126
+ """Verifies a new task starts only AFTER previous hooks finished."""
127
+ test_tasks_file, initial_data = setup_env
128
+ # 1. Setup two pending tasks.
129
+ now = time.time()
130
+ with tasks.lock_tasks(test_tasks_file):
131
+ data = tasks.load_tasks(test_tasks_file)
132
+ data.tasks = [
133
+ tasks.Task(
134
+ id="task2",
135
+ description="Task 2",
136
+ status=tasks.TaskStatus.PENDING,
137
+ created_at=now,
138
+ ),
139
+ tasks.Task(
140
+ id="task1",
141
+ description="Task 1",
142
+ status=tasks.TaskStatus.PENDING,
143
+ created_at=now + 10,
144
+ ),
145
+ ]
146
+ data.config.retries = 1
147
+ data.config.runner = "true"
148
+ tasks.save_tasks(test_tasks_file, data)
149
+
150
+ task_starts = {}
151
+ hook_ends = {}
152
+
153
+ def mocked_run_with_heartbeat(
154
+ cmd, t_file, t_id, verbose, echo_fn, header=None, cwd=None, time_limit=0
155
+ ):
156
+ if header and header.startswith("Hook:"):
157
+ time.sleep(0.1)
158
+ hook_ends[t_id] = time.time()
159
+ return 0, "hook stdout", ""
160
+ else:
161
+ task_starts[t_id] = time.time()
162
+ return 0, "task stdout", ""
163
+
164
+ def mocked_finish_task_attempt(t_file, t_id):
165
+ with tasks.lock_tasks(t_file):
166
+ data = tasks.load_tasks(t_file)
167
+ task = next(t for t in data.tasks if t.id == t_id)
168
+ task.requested_status = tasks.TaskStatus.COMPLETED
169
+ task.status = tasks.TaskStatus.IN_PROGRESS
170
+ tasks.save_tasks(t_file, data)
171
+ return task
172
+
173
+ with (
174
+ mock.patch(
175
+ "lemming.runner.run_with_heartbeat",
176
+ side_effect=mocked_run_with_heartbeat,
177
+ ),
178
+ mock.patch(
179
+ "lemming.tasks.finish_task_attempt",
180
+ side_effect=mocked_finish_task_attempt,
181
+ ),
182
+ mock.patch("lemming.prompts.list_hooks", return_value=["test_hook"]),
183
+ mock.patch(
184
+ "lemming.prompts.prepare_hook_prompt", return_value="Dummy hook"
185
+ ),
186
+ ):
187
+ run_loop(
188
+ test_tasks_file,
189
+ verbose=True,
190
+ retry_delay=0,
191
+ yolo=True,
192
+ no_defaults=False,
193
+ runner_args=(),
194
+ )
195
+
196
+ assert "task1" in task_starts
197
+ assert "task2" in task_starts
198
+ assert "task2" in hook_ends
199
+
200
+ assert task_starts["task1"] >= hook_ends["task2"], (
201
+ "Task 1 should start after Task 2 hooks finish"
202
+ )
203
+
204
+
205
+ @mock.patch("lemming.runner.run_with_heartbeat")
206
+ def test_run_loop_calls_runner_with_header(mock_run, setup_env):
207
+ test_tasks_file, initial_data = setup_env
208
+ # Setup runner mock
209
+ mock_run.return_value = (0, "output", "")
210
+
211
+ # Mock finish_task_attempt to return a completed task to end loop
212
+ mock_task = initial_data.tasks[0]
213
+ mock_task.status = tasks.TaskStatus.COMPLETED
214
+ with mock.patch(
215
+ "lemming.tasks.finish_task_attempt", return_value=mock_task
216
+ ):
217
+ run_loop(
218
+ test_tasks_file,
219
+ verbose=False,
220
+ retry_delay=0,
221
+ yolo=True,
222
+ no_defaults=False,
223
+ runner_args=(),
224
+ )
225
+
226
+ # Verify run_with_heartbeat was called with header="Task Runner"
227
+ args, kwargs = mock_run.call_args
228
+ assert kwargs.get("header") == "Task Runner"
229
+
230
+
231
+ @mock.patch("lemming.runner.run_with_heartbeat")
232
+ @mock.patch("time.sleep", return_value=None)
233
+ def test_run_loop_cancelled(mock_sleep, mock_run, setup_env):
234
+ test_tasks_file, initial_data = setup_env
235
+ # Simulate a task being cancelled (exit code -15)
236
+ mock_run.return_value = (-15, "cancelled", "error")
237
+
238
+ # Configure retries to ensure it DOES NOT retry
239
+ data = tasks.load_tasks(test_tasks_file)
240
+ data.config.retries = 3
241
+ tasks.save_tasks(test_tasks_file, data)
242
+
243
+ run_loop(
244
+ test_tasks_file,
245
+ verbose=True,
246
+ retry_delay=1,
247
+ yolo=True,
248
+ no_defaults=False,
249
+ runner_args=(),
250
+ )
251
+
252
+ # It should only have 1 attempt
253
+ data = tasks.load_tasks(test_tasks_file)
254
+ assert data.tasks[0].attempts == 1
255
+
256
+ # Verify that sleep was NOT called with the retry_delay (1)
257
+ # It might be called with other values if I didn't mock enough,
258
+ # but here it should not be called at all after the break.
259
+ for call in mock_sleep.call_args_list:
260
+ assert call[0][0] != 1, (
261
+ "Should not sleep with retry_delay on cancellation"
262
+ )
263
+
264
+
265
+ @mock.patch("lemming.runner.run_with_heartbeat")
266
+ @mock.patch("time.sleep", return_value=None)
267
+ def test_run_loop_timeout_retries(mock_sleep, mock_run, setup_env):
268
+ """Verifies that a timed-out task is retried without running hooks."""
269
+ test_tasks_file, initial_data = setup_env
270
+ # First call: timeout. Second call: timeout again. Exhausts retries.
271
+ mock_run.return_value = (RETURNCODE_TIMEOUT, "output", "")
272
+
273
+ data = tasks.load_tasks(test_tasks_file)
274
+ data.config.retries = 2
275
+ data.config.time_limit = 60
276
+ tasks.save_tasks(test_tasks_file, data)
277
+
278
+ run_loop(
279
+ test_tasks_file,
280
+ verbose=False,
281
+ retry_delay=0,
282
+ yolo=True,
283
+ no_defaults=False,
284
+ runner_args=(),
285
+ )
286
+
287
+ data = tasks.load_tasks(test_tasks_file)
288
+ assert data.tasks[0].attempts == 2
289
+ assert data.tasks[0].status == tasks.TaskStatus.FAILED
290
+
291
+
292
+ @mock.patch("lemming.runner.run_with_heartbeat")
293
+ @mock.patch("time.sleep", return_value=None)
294
+ def test_run_loop_passes_time_limit(mock_sleep, mock_run, setup_env):
295
+ """Verifies that time_limit from config is passed to the runner."""
296
+ test_tasks_file, initial_data = setup_env
297
+ mock_run.return_value = (0, "output", "")
298
+
299
+ data = tasks.load_tasks(test_tasks_file)
300
+ data.config.time_limit = 30
301
+ tasks.save_tasks(test_tasks_file, data)
302
+
303
+ mock_task = initial_data.tasks[0]
304
+ mock_task.status = tasks.TaskStatus.COMPLETED
305
+ with mock.patch(
306
+ "lemming.tasks.finish_task_attempt", return_value=mock_task
307
+ ):
308
+ run_loop(
309
+ test_tasks_file,
310
+ verbose=False,
311
+ retry_delay=0,
312
+ yolo=True,
313
+ no_defaults=False,
314
+ runner_args=(),
315
+ )
316
+
317
+ _, kwargs = mock_run.call_args
318
+ assert kwargs.get("time_limit") == 30
319
+
320
+
321
+ @mock.patch("lemming.orchestrator.run_hooks")
322
+ def test_process_exhausted_retries_aborts(mock_run_hooks, setup_env):
323
+ test_tasks_file, initial_data = setup_env
324
+ # 1. Setup task that has exhausted retries and won't be healed
325
+ data = tasks.load_tasks(test_tasks_file)
326
+ task = data.tasks[0]
327
+ task.attempts = 3
328
+ tasks.save_tasks(test_tasks_file, data)
329
+
330
+ should_abort = _process_exhausted_retries(
331
+ test_tasks_file,
332
+ task.id,
333
+ retries=3,
334
+ runner_name="agy",
335
+ yolo=True,
336
+ runner_args=(),
337
+ no_defaults=False,
338
+ verbose=False,
339
+ active_hooks=["roadmap"],
340
+ working_dir=None,
341
+ time_limit=1,
342
+ )
343
+ assert should_abort
344
+ mock_run_hooks.assert_called_once()
345
+ assert (
346
+ mock_run_hooks.call_args[1]["final_status"] == tasks.TaskStatus.FAILED
347
+ )
348
+
349
+
350
+ @mock.patch("lemming.orchestrator.run_hooks")
351
+ def test_process_exhausted_retries_healed(mock_run_hooks, setup_env):
352
+ test_tasks_file, initial_data = setup_env
353
+
354
+ # Setup task but simulate a hook healing it by resetting attempts
355
+ def fake_run_hooks(*args, **kwargs):
356
+ data = tasks.load_tasks(test_tasks_file)
357
+ data.tasks[0].attempts = 0 # Healed!
358
+ tasks.save_tasks(test_tasks_file, data)
359
+
360
+ mock_run_hooks.side_effect = fake_run_hooks
361
+
362
+ data = tasks.load_tasks(test_tasks_file)
363
+ task = data.tasks[0]
364
+ task.attempts = 3
365
+ tasks.save_tasks(test_tasks_file, data)
366
+
367
+ should_abort = _process_exhausted_retries(
368
+ test_tasks_file,
369
+ task.id,
370
+ retries=3,
371
+ runner_name="agy",
372
+ yolo=True,
373
+ runner_args=(),
374
+ no_defaults=False,
375
+ verbose=False,
376
+ active_hooks=["roadmap"],
377
+ working_dir=None,
378
+ time_limit=1,
379
+ )
380
+ assert not should_abort
381
+ mock_run_hooks.assert_called_once()
382
+
383
+
384
+ @mock.patch("lemming.orchestrator.run_hooks")
385
+ def test_process_finalizing_task(mock_run_hooks, setup_env):
386
+ test_tasks_file, initial_data = setup_env
387
+ _process_finalizing_task(
388
+ test_tasks_file,
389
+ "task1",
390
+ requested_status=tasks.TaskStatus.COMPLETED,
391
+ runner_name="agy",
392
+ yolo=True,
393
+ runner_args=(),
394
+ no_defaults=False,
395
+ verbose=False,
396
+ active_hooks=["readability"],
397
+ working_dir=None,
398
+ time_limit=1,
399
+ )
400
+ mock_run_hooks.assert_called_once()
401
+ assert (
402
+ mock_run_hooks.call_args[1]["final_status"]
403
+ == tasks.TaskStatus.COMPLETED
404
+ )
405
+
406
+
407
+ @mock.patch("lemming.orchestrator.run_hooks")
408
+ @mock.patch("time.sleep", return_value=None)
409
+ def test_handle_runner_exit_completes(mock_sleep, mock_run_hooks, setup_env):
410
+ test_tasks_file, initial_data = setup_env
411
+ # A task requesting completion runs hooks and doesn't abort loop
412
+ data = tasks.load_tasks(test_tasks_file)
413
+ task = data.tasks[0]
414
+ task.status = tasks.TaskStatus.IN_PROGRESS
415
+ tasks.save_tasks(test_tasks_file, data)
416
+
417
+ # Simulate agent calling `lemming complete` (finish_task_attempt
418
+ # checks requested_status)
419
+ tasks.update_task(
420
+ test_tasks_file, task.id, status=tasks.TaskStatus.COMPLETED
421
+ )
422
+
423
+ should_abort = _handle_runner_exit(
424
+ test_tasks_file,
425
+ task.id,
426
+ returncode=0,
427
+ stdout="done",
428
+ stderr="",
429
+ retries=3,
430
+ retry_delay=5,
431
+ runner_name="agy",
432
+ yolo=True,
433
+ runner_args=(),
434
+ no_defaults=False,
435
+ verbose=False,
436
+ active_hooks=[],
437
+ working_dir=None,
438
+ time_limit=1,
439
+ )
440
+ assert not should_abort
441
+
442
+
443
+ @mock.patch("lemming.orchestrator.run_hooks")
444
+ def test_handle_runner_exit_cancelled(mock_run_hooks, setup_env):
445
+ test_tasks_file, initial_data = setup_env
446
+ data = tasks.load_tasks(test_tasks_file)
447
+ task = data.tasks[0]
448
+ task.status = tasks.TaskStatus.IN_PROGRESS
449
+ tasks.save_tasks(test_tasks_file, data)
450
+
451
+ should_abort = _handle_runner_exit(
452
+ test_tasks_file,
453
+ task.id,
454
+ returncode=-15,
455
+ stdout="",
456
+ stderr="",
457
+ retries=3,
458
+ retry_delay=5,
459
+ runner_name="agy",
460
+ yolo=True,
461
+ runner_args=(),
462
+ no_defaults=False,
463
+ verbose=False,
464
+ active_hooks=[],
465
+ working_dir=None,
466
+ time_limit=1,
467
+ )
468
+ assert should_abort, "Cancellation (-15) should abort the loop"
lemming/paths.py ADDED
@@ -0,0 +1,245 @@
1
+ """Path resolution, .env loading, and git helpers for Lemming."""
2
+
3
+ import functools
4
+ import hashlib
5
+ import logging
6
+ import os
7
+ import pathlib
8
+ import stat
9
+ import subprocess
10
+
11
+ logger = logging.getLogger(__name__)
12
+
13
+
14
+ def _parse_dotenv(path: pathlib.Path) -> dict[str, str]:
15
+ """Parse a .env file into a dict, skipping comments and blank lines."""
16
+ result: dict[str, str] = {}
17
+ try:
18
+ for lineno, raw_line in enumerate(path.read_text().splitlines(), 1):
19
+ line = raw_line.strip()
20
+ if not line or line.startswith("#"):
21
+ continue
22
+ # Optional "export " prefix
23
+ if line.startswith("export "):
24
+ line = line[7:].strip()
25
+ if "=" not in line:
26
+ logger.warning(
27
+ "%s:%d: skipping malformed line (no '=')", path, lineno
28
+ )
29
+ continue
30
+ key, value = line.split("=", 1)
31
+ key = key.strip()
32
+ # Strip surrounding quotes from value
33
+ value = value.strip()
34
+ if (
35
+ len(value) >= 2
36
+ and value[0] == value[-1]
37
+ and value[0] in ('"', "'")
38
+ ):
39
+ value = value[1:-1]
40
+ result[key] = value
41
+ except FileNotFoundError:
42
+ pass
43
+ except Exception as exc:
44
+ logger.warning("Failed to read %s: %s", path, exc)
45
+ return result
46
+
47
+
48
+ def _check_permissions(path: pathlib.Path) -> None:
49
+ """Warn if the .env file is readable by group or others."""
50
+ try:
51
+ mode = path.stat().st_mode
52
+ if mode & (stat.S_IRGRP | stat.S_IROTH):
53
+ logger.warning(
54
+ "%s has overly permissive permissions (%o). "
55
+ "Consider restricting to 600.",
56
+ path,
57
+ stat.S_IMODE(mode),
58
+ )
59
+ except OSError:
60
+ pass
61
+
62
+
63
+ def load_dotenv(project_dir: pathlib.Path | None = None) -> None:
64
+ """Load environment variables from .env files.
65
+
66
+ Precedence (later wins, but real env vars always take priority):
67
+ 1. ~/.local/lemming/.env (global defaults)
68
+ 2. <project_dir>/.env (project overrides)
69
+
70
+ Existing environment variables are never overwritten.
71
+ """
72
+ env_files: list[pathlib.Path] = []
73
+
74
+ global_env = get_lemming_home() / ".env"
75
+ if global_env.is_file():
76
+ env_files.append(global_env)
77
+
78
+ if project_dir is not None:
79
+ project_env = project_dir / ".env"
80
+ if project_env.is_file() and project_env != global_env:
81
+ env_files.append(project_env)
82
+
83
+ merged: dict[str, str] = {}
84
+ for env_file in env_files:
85
+ _check_permissions(env_file)
86
+ merged.update(_parse_dotenv(env_file))
87
+
88
+ # Only set vars that aren't already in the environment
89
+ for key, value in merged.items():
90
+ if key not in os.environ:
91
+ os.environ[key] = value
92
+
93
+
94
+ def get_lemming_home() -> pathlib.Path:
95
+ """Determines the Lemming home directory from the environment or default.
96
+
97
+ Returns:
98
+ A pathlib.Path representing the Lemming home directory.
99
+ """
100
+ home_override = os.environ.get("LEMMING_HOME")
101
+ if home_override:
102
+ return pathlib.Path(home_override)
103
+ return pathlib.Path.home() / ".local" / "lemming"
104
+
105
+
106
+ def get_global_hooks_dir() -> pathlib.Path:
107
+ """Returns the global hooks directory in Lemming home."""
108
+ return get_lemming_home() / "hooks"
109
+
110
+
111
+ def get_project_dir(tasks_file: pathlib.Path) -> pathlib.Path:
112
+ """Determines the isolated project directory for a given tasks file.
113
+
114
+ Args:
115
+ tasks_file: Path to the tasks YAML file.
116
+
117
+ Returns:
118
+ A pathlib.Path to the isolated directory where project logs and state
119
+ should be stored.
120
+ """
121
+ tasks_file_abs = tasks_file.resolve()
122
+ lemming_home = get_lemming_home()
123
+
124
+ # If the tasks file is already inside lemming home, its parent IS the
125
+ # project dir.
126
+ if tasks_file_abs.parent.parent == lemming_home:
127
+ return tasks_file_abs.parent
128
+
129
+ # Otherwise, hash the absolute path of the tasks file to get a unique
130
+ # project dir.
131
+ path_hash = hashlib.sha256(str(tasks_file_abs).encode()).hexdigest()[:12]
132
+ return lemming_home / path_hash
133
+
134
+
135
+ def get_tasks_file_for_dir(directory: pathlib.Path) -> pathlib.Path:
136
+ """Returns the tasks file location for a given project directory.
137
+
138
+ Checks for a local `tasks.yml` first, then falls back to an isolated
139
+ project tasks file in the Lemming home directory.
140
+
141
+ Args:
142
+ directory: The resolved absolute path to the project directory.
143
+
144
+ Returns:
145
+ A pathlib.Path to the tasks file for that directory.
146
+ """
147
+ local_tasks = directory / "tasks.yml"
148
+ if local_tasks.exists():
149
+ return local_tasks
150
+
151
+ path_hash = hashlib.sha256(str(directory).encode()).hexdigest()[:12]
152
+ return get_lemming_home() / path_hash / "tasks.yml"
153
+
154
+
155
+ def get_default_tasks_file() -> pathlib.Path:
156
+ """Returns the default tasks file location based on the current directory.
157
+
158
+ Returns:
159
+ A pathlib.Path to the default tasks file.
160
+ """
161
+ return get_tasks_file_for_dir(pathlib.Path.cwd().resolve())
162
+
163
+
164
+ def get_working_dir(tasks_file: pathlib.Path) -> pathlib.Path:
165
+ """Returns the intended working directory for a tasks file.
166
+
167
+ If the tasks file is NOT in the lemming home directory, its parent
168
+ is assumed to be the working directory.
169
+
170
+ If it IS in the lemming home directory, we return the current
171
+ working directory as a fallback.
172
+ """
173
+ tasks_file_abs = tasks_file.resolve()
174
+ lemming_home = get_lemming_home()
175
+
176
+ if lemming_home in tasks_file_abs.parents:
177
+ # It's an isolated tasks file. We don't know the original source dir
178
+ # unless it was passed to us or stored in the file.
179
+ # For now, return CWD.
180
+ return pathlib.Path.cwd().resolve()
181
+
182
+ # It's a local tasks.yml file. Its parent is the project root.
183
+ return tasks_file_abs.parent
184
+
185
+
186
+ def get_log_file(tasks_file: pathlib.Path, task_id: str) -> pathlib.Path:
187
+ """Returns the log file path for a specific task.
188
+
189
+ Args:
190
+ tasks_file: Path to the tasks YAML file associated with the task.
191
+ task_id: The unique task ID.
192
+
193
+ Returns:
194
+ A pathlib.Path to the log file for the given task.
195
+ """
196
+ project_dir = get_project_dir(tasks_file)
197
+ project_dir.mkdir(parents=True, exist_ok=True)
198
+ return project_dir / f"{task_id}-runner.log"
199
+
200
+
201
+ @functools.cache
202
+ def in_git_repo() -> bool:
203
+ """Check if the current directory is inside a git repository.
204
+
205
+ The result is cached after the first call; use
206
+ `in_git_repo.cache_clear()` to reset it.
207
+
208
+ Returns:
209
+ True if inside a git repository, False otherwise.
210
+ """
211
+ try:
212
+ return (
213
+ subprocess.run(
214
+ ["git", "rev-parse", "--git-dir"],
215
+ capture_output=True,
216
+ check=False,
217
+ ).returncode
218
+ == 0
219
+ )
220
+ except Exception:
221
+ return False
222
+
223
+
224
+ def is_ignored(path: pathlib.Path) -> bool:
225
+ """Check if a given path is ignored by git.
226
+
227
+ Args:
228
+ path: The path to check for git-ignore status.
229
+
230
+ Returns:
231
+ True if the path is ignored by git, False otherwise.
232
+ """
233
+ if not in_git_repo():
234
+ return False
235
+ try:
236
+ return (
237
+ subprocess.run(
238
+ ["git", "check-ignore", "-q", str(path)],
239
+ capture_output=True,
240
+ check=False,
241
+ ).returncode
242
+ == 0
243
+ )
244
+ except Exception:
245
+ return False