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
lemming/cli/tasks.py ADDED
@@ -0,0 +1,529 @@
1
+ """CLI commands for managing tasks in the roadmap queue."""
2
+
3
+ import time
4
+ import typing
5
+
6
+ import click
7
+
8
+ from .. import paths, tasks
9
+ from .main import cli
10
+
11
+
12
+ @cli.command(short_help="[description] Add a new task to the queue")
13
+ @click.argument("description", required=False)
14
+ @click.option(
15
+ "--file",
16
+ "-f",
17
+ type=click.File("r"),
18
+ help="Read description from a file (or - for stdin).",
19
+ )
20
+ @click.option(
21
+ "--index",
22
+ default=-1,
23
+ help="Index to insert the task at (defaults to -1, the end).",
24
+ )
25
+ @click.option(
26
+ "--runner",
27
+ "runner_name",
28
+ help=(
29
+ "Custom runner to use for this task (overrides the default run runner)."
30
+ ),
31
+ )
32
+ @click.option(
33
+ "--parent",
34
+ help="ID of the parent task.",
35
+ )
36
+ @click.option(
37
+ "--parent-tasks-file",
38
+ help="Path to the parent tasks file (optional).",
39
+ )
40
+ @click.pass_context
41
+ def add(
42
+ ctx: click.Context,
43
+ description: str | None,
44
+ file: typing.Optional[typing.TextIO],
45
+ index: int,
46
+ runner_name: str | None,
47
+ parent: str | None,
48
+ parent_tasks_file: str | None,
49
+ ):
50
+ """Adds a new task to the roadmap queue.
51
+
52
+ Args:
53
+ ctx: The click context.
54
+ description: A text description of the task to perform (optional if
55
+ --file is used).
56
+ file: An optional file to read the description from.
57
+ index: The position in the roadmap to insert the task.
58
+ runner_name: An optional custom runner to use for this specific task.
59
+ parent: Optional parent task ID.
60
+ parent_tasks_file: Optional parent tasks file path.
61
+ """
62
+ tasks_file = ctx.obj["TASKS_FILE"]
63
+ verbose = ctx.obj["VERBOSE"]
64
+
65
+ if file:
66
+ if description:
67
+ click.echo("Error: Cannot provide both description and --file.")
68
+ ctx.exit(1)
69
+ description = file.read().strip()
70
+ elif description:
71
+ description = description.strip()
72
+
73
+ if not description:
74
+ click.echo("Error: Must provide either description or --file.")
75
+ ctx.exit(1)
76
+
77
+ new_task = tasks.add_task(
78
+ tasks_file,
79
+ description,
80
+ runner_name,
81
+ index=index,
82
+ parent=parent,
83
+ parent_tasks_file=parent_tasks_file,
84
+ )
85
+ task_id = new_task.id
86
+
87
+ if verbose:
88
+ click.echo(f"Added task {task_id}: {description}")
89
+ else:
90
+ click.echo(task_id)
91
+
92
+
93
+ @cli.command(short_help="<taskid> Edit an existing task's details")
94
+ @click.argument("task_id")
95
+ @click.option("--description", help="New description for the task.")
96
+ @click.option(
97
+ "--file",
98
+ "-f",
99
+ type=click.File("r"),
100
+ help="Read new description from a file (or - for stdin).",
101
+ )
102
+ @click.option("--runner", "runner_name", help="New custom runner for the task.")
103
+ @click.option("--index", type=int, help="New index in the task queue.")
104
+ @click.option(
105
+ "--parent",
106
+ help="New parent task ID (use empty string to remove).",
107
+ )
108
+ @click.option(
109
+ "--parent-tasks-file",
110
+ help="New parent tasks file path (use empty string to remove).",
111
+ )
112
+ @click.pass_context
113
+ def edit(
114
+ ctx: click.Context,
115
+ task_id: str,
116
+ description: str | None,
117
+ file: typing.Optional[typing.TextIO],
118
+ runner_name: str | None,
119
+ index: int | None,
120
+ parent: str | None,
121
+ parent_tasks_file: str | None,
122
+ ):
123
+ """Edits an existing task's description, runner, position, or parent.
124
+
125
+ Args:
126
+ ctx: The click context.
127
+ task_id: The ID of the task to update.
128
+ description: The new description (optional).
129
+ file: An optional file to read the new description from.
130
+ runner_name: The new preferred runner (optional).
131
+ index: The new position in the roadmap (optional).
132
+ parent: The new parent task ID (optional).
133
+ parent_tasks_file: The new parent tasks file path (optional).
134
+ """
135
+ if file:
136
+ if description:
137
+ click.echo("Error: Cannot provide both description and --file.")
138
+ ctx.exit(1)
139
+ description = file.read().strip()
140
+ elif description:
141
+ description = description.strip()
142
+
143
+ if (
144
+ description is None
145
+ and runner_name is None
146
+ and index is None
147
+ and parent is None
148
+ and parent_tasks_file is None
149
+ ):
150
+ click.echo(
151
+ "Error: At least one of --description, --runner, --index,"
152
+ " --parent, or --parent-tasks-file must be provided."
153
+ )
154
+ ctx.exit(1)
155
+
156
+ tasks_file = ctx.obj["TASKS_FILE"]
157
+
158
+ try:
159
+ target_task = tasks.update_task(
160
+ tasks_file,
161
+ task_id,
162
+ description=description,
163
+ runner=runner_name,
164
+ index=index,
165
+ parent=parent,
166
+ parent_tasks_file=parent_tasks_file,
167
+ )
168
+ click.echo(f"Task {target_task.id} updated.")
169
+ except ValueError as e:
170
+ click.echo(f"Error: {e}")
171
+ ctx.exit(1)
172
+
173
+
174
+ @cli.command(name="delete", short_help="<taskid> Delete a task from the queue")
175
+ @click.argument("task_id", required=False)
176
+ @click.option(
177
+ "--all",
178
+ "delete_all",
179
+ is_flag=True,
180
+ help="Delete all tasks and clear context.",
181
+ )
182
+ @click.option("--completed", is_flag=True, help="Delete completed tasks only.")
183
+ @click.pass_context
184
+ def delete_task(
185
+ ctx: click.Context, task_id: str | None, delete_all: bool, completed: bool
186
+ ):
187
+ """Deletes one or more tasks from the roadmap.
188
+
189
+ Args:
190
+ ctx: The click context.
191
+ task_id: The ID of the specific task to delete.
192
+ delete_all: If set, clears the entire roadmap and project context.
193
+ completed: If set, deletes all tasks marked as 'completed'.
194
+ """
195
+ tasks_file = ctx.obj["TASKS_FILE"]
196
+
197
+ # Validate argument combinations
198
+ if delete_all and completed:
199
+ click.echo("Error: --all and --completed are mutually exclusive.")
200
+ ctx.exit(1)
201
+ if task_id and (delete_all or completed):
202
+ click.echo("Error: Cannot specify a task ID with --all or --completed.")
203
+ ctx.exit(1)
204
+ if not task_id and not delete_all and not completed:
205
+ click.echo("Error: Provide a task ID, or use --all or --completed.")
206
+ ctx.exit(1)
207
+
208
+ removed = tasks.delete_tasks(
209
+ tasks_file,
210
+ task_id=task_id,
211
+ all_tasks=delete_all,
212
+ completed_only=completed,
213
+ )
214
+
215
+ if delete_all:
216
+ click.echo(
217
+ "Deleted all tasks, progress, and logs, and cleared context."
218
+ )
219
+ elif completed:
220
+ click.echo(f"Deleted {removed} completed task(s) and their logs.")
221
+ elif task_id:
222
+ if removed > 0:
223
+ click.echo(f"Removed task(s) matching {task_id} and their logs")
224
+ else:
225
+ click.echo(f"Error: Task {task_id} not found.")
226
+
227
+
228
+ @cli.command(short_help="<taskid> Show context and task details")
229
+ @click.argument("task_id", required=False)
230
+ @click.pass_context
231
+ def status(ctx: click.Context, task_id: str | None):
232
+ """Displays the roadmap status or details for a specific task.
233
+
234
+ Args:
235
+ ctx: The click context.
236
+ task_id: Optional ID of the task to inspect in detail.
237
+ """
238
+ tasks_file = ctx.obj["TASKS_FILE"]
239
+ verbose = ctx.obj["VERBOSE"]
240
+ project_data = tasks.get_project_data(tasks_file)
241
+
242
+ if not task_id:
243
+ if project_data.loop_running:
244
+ loop_state = "Running"
245
+ loop_color = "green"
246
+ else:
247
+ loop_state = "Idle"
248
+ loop_color = "cyan"
249
+
250
+ click.secho(f"Loop Status: {loop_state}", fg=loop_color, bold=True)
251
+ click.echo()
252
+
253
+ if verbose:
254
+ click.secho("=== Project Context ===", fg="cyan", bold=True)
255
+ click.echo(project_data.context or "No context set.")
256
+ click.secho("\n=== Tasks ===", fg="cyan", bold=True)
257
+
258
+ if not project_data.tasks:
259
+ if verbose:
260
+ click.echo("No tasks found.")
261
+ return
262
+
263
+ for t in project_data.tasks:
264
+ if not verbose and t.status in (
265
+ tasks.TaskStatus.COMPLETED,
266
+ tasks.TaskStatus.CANCELLED,
267
+ ):
268
+ continue
269
+
270
+ if t.status == tasks.TaskStatus.COMPLETED:
271
+ marker = "[x]"
272
+ status_color = "green"
273
+ elif t.status == tasks.TaskStatus.IN_PROGRESS:
274
+ marker = "[*]"
275
+ status_color = "cyan"
276
+ elif t.status == tasks.TaskStatus.CANCELLED:
277
+ marker = "[-]"
278
+ status_color = "red"
279
+ else:
280
+ marker = "[ ]"
281
+ status_color = "yellow"
282
+
283
+ click.secho(f"{marker} ", fg=status_color, nl=False)
284
+ parent_str = ""
285
+ if t.parent:
286
+ parent_str = f" [parent:{t.parent}]"
287
+
288
+ click.echo(f"({t.id}){parent_str} {t.description}")
289
+
290
+ if not verbose:
291
+ completed_count = sum(
292
+ 1
293
+ for t in project_data.tasks
294
+ if t.status
295
+ in (tasks.TaskStatus.COMPLETED, tasks.TaskStatus.CANCELLED)
296
+ )
297
+ if completed_count > 0:
298
+ click.echo(
299
+ f"({completed_count} completed/cancelled tasks hidden)"
300
+ )
301
+ return
302
+
303
+ target = next(
304
+ (t for t in project_data.tasks if t.id.startswith(task_id)), None
305
+ )
306
+
307
+ if not target:
308
+ click.echo(f"Error: Task {task_id} not found.")
309
+ return
310
+
311
+ if project_data.loop_running:
312
+ loop_state = "Running"
313
+ loop_color = "green"
314
+ else:
315
+ loop_state = "Idle"
316
+ loop_color = "cyan"
317
+
318
+ click.secho(f"Loop Status: {loop_state}", fg=loop_color, bold=True)
319
+ click.secho(f"Task ID: {target.id}", bold=True)
320
+ status_str = str(target.status)
321
+ if (
322
+ target.status == tasks.TaskStatus.IN_PROGRESS
323
+ and target.requested_status
324
+ ):
325
+ status_str += f" ({target.requested_status} requested, hooks running)"
326
+ click.echo(f"Status: {status_str}")
327
+ click.echo(f"Description: {target.description}")
328
+ if target.parent:
329
+ click.echo(f"Parent: {target.parent}")
330
+ if target.runner:
331
+ click.echo(f"Custom Runner: {target.runner}")
332
+ click.echo(f"Attempts: {target.attempts}")
333
+ if target.created_at:
334
+ created_time = time.strftime(
335
+ "%Y-%m-%d %H:%M:%S %Z", time.localtime(target.created_at)
336
+ )
337
+ click.echo(f"Created At: {created_time}")
338
+
339
+ log_parts = []
340
+ if paths.get_log_file(tasks_file, target.id).exists():
341
+ log_parts.append("runner (includes hooks)")
342
+ click.echo(
343
+ f"Logs: {', '.join(log_parts) if log_parts else 'None'}"
344
+ )
345
+
346
+ if target.completed_at:
347
+ comp_time = time.strftime(
348
+ "%Y-%m-%d %H:%M:%S %Z", time.localtime(target.completed_at)
349
+ )
350
+ click.echo(f"Completed At: {comp_time}")
351
+ run_time = target.run_time
352
+ if target.status == tasks.TaskStatus.IN_PROGRESS and target.last_started_at:
353
+ run_time += time.time() - target.last_started_at
354
+
355
+ if run_time > 0:
356
+ if run_time < 60:
357
+ rt_str = f"{run_time:.1f}s"
358
+ else:
359
+ rt_str = f"{int(run_time // 60)}m {int(run_time % 60)}s"
360
+ click.echo(f"Run Time: {rt_str}")
361
+
362
+ if target.progress:
363
+ click.secho("\n--- Progress ---", fg="magenta", bold=True)
364
+ for i, entry in enumerate(target.progress):
365
+ click.echo(f"[{i}] {entry}")
366
+
367
+
368
+ @cli.command(short_help="[<taskid>] Print a task's log to stdout")
369
+ @click.argument("task_id", required=False)
370
+ @click.pass_context
371
+ def logs(ctx: click.Context, task_id: str | None):
372
+ """Prints the execution log for a task to stdout.
373
+
374
+ If no task_id is provided, it defaults to the currently running task or
375
+ the most recently completed one.
376
+
377
+ Note: Orchestrator hooks are appended to the main 'runner' log.
378
+ """
379
+ tasks_file = ctx.obj["TASKS_FILE"]
380
+
381
+ data = tasks.load_tasks(tasks_file)
382
+
383
+ target = None
384
+ if task_id:
385
+ target = next((t for t in data.tasks if t.id.startswith(task_id)), None)
386
+ if not target:
387
+ click.echo(f"Error: Task {task_id} not found.")
388
+ ctx.exit(1)
389
+ else:
390
+ # Try to find an active task
391
+ target = next(
392
+ (t for t in data.tasks if t.status == tasks.TaskStatus.IN_PROGRESS),
393
+ None,
394
+ )
395
+ if not target:
396
+ # Fall back to the most recently completed task
397
+ completed = [
398
+ t for t in data.tasks if t.status == tasks.TaskStatus.COMPLETED
399
+ ]
400
+ if completed:
401
+ target = sorted(completed, key=lambda x: x.completed_at or 0)[
402
+ -1
403
+ ]
404
+
405
+ if not target:
406
+ click.echo("Error: No active or recently completed task found.")
407
+ ctx.exit(1)
408
+
409
+ log_file = paths.get_log_file(tasks_file, target.id)
410
+ if not log_file.exists():
411
+ click.echo(f"No log for task {target.id}.")
412
+ ctx.exit(1)
413
+
414
+ # Highlight separators for better readability in the terminal
415
+ content = log_file.read_text(encoding="utf-8")
416
+ for line in content.splitlines():
417
+ if line.startswith("--- ") and line.endswith(" ---"):
418
+ click.secho(line, fg="cyan", bold=True)
419
+ else:
420
+ click.echo(line)
421
+
422
+
423
+ @cli.command(short_help="<taskid> Mark a task as completed")
424
+ @click.argument("task_id")
425
+ @click.pass_context
426
+ def complete(ctx: click.Context, task_id: str):
427
+ """Marks a task as completed (requires at least one progress entry).
428
+
429
+ Args:
430
+ ctx: The click context.
431
+ task_id: The ID of the task to mark as completed.
432
+ """
433
+ tasks_file = ctx.obj["TASKS_FILE"]
434
+
435
+ try:
436
+ target_task = tasks.update_task(
437
+ tasks_file,
438
+ task_id,
439
+ status=tasks.TaskStatus.COMPLETED,
440
+ require_progress=True,
441
+ )
442
+ click.echo(f"Task {target_task.id} marked as completed.")
443
+ except ValueError as e:
444
+ click.echo(f"Error: {e}")
445
+ ctx.exit(1)
446
+
447
+
448
+ @cli.command(short_help="<taskid> Mark a completed task as pending")
449
+ @click.argument("task_id")
450
+ @click.pass_context
451
+ def uncomplete(ctx: click.Context, task_id: str):
452
+ """Unmarks a completed task, moving it back to 'pending' status.
453
+
454
+ Args:
455
+ ctx: The click context.
456
+ task_id: The ID of the task to uncomplete.
457
+ """
458
+ tasks_file = ctx.obj["TASKS_FILE"]
459
+ try:
460
+ target_task = tasks.update_task(
461
+ tasks_file, task_id, status=tasks.TaskStatus.PENDING
462
+ )
463
+ click.echo(f"Task {target_task.id} marked as pending.")
464
+ except ValueError as e:
465
+ click.echo(f"Error: {e}")
466
+ ctx.exit(1)
467
+
468
+
469
+ @cli.command(short_help="<taskid> Record a task failure")
470
+ @click.argument("task_id")
471
+ @click.pass_context
472
+ def fail(ctx: click.Context, task_id: str):
473
+ """Marks a task as failed (requires at least one recorded progress entry).
474
+
475
+ Args:
476
+ ctx: The click context.
477
+ task_id: The ID of the task to mark as failed.
478
+ """
479
+ tasks_file = ctx.obj["TASKS_FILE"]
480
+ try:
481
+ target_task = tasks.update_task(
482
+ tasks_file,
483
+ task_id,
484
+ status=tasks.TaskStatus.FAILED,
485
+ require_progress=True,
486
+ )
487
+ click.echo(f"Task {target_task.id} marked as failed.")
488
+ except ValueError as e:
489
+ click.echo(f"Error: {e}")
490
+ ctx.exit(1)
491
+
492
+
493
+ @cli.command(short_help="<taskid> Stop an in-progress task")
494
+ @click.argument("task_id")
495
+ @click.pass_context
496
+ def cancel(ctx: click.Context, task_id: str):
497
+ """Kills the runner for an in-progress task and marks it as cancelled.
498
+
499
+ Args:
500
+ ctx: The click context.
501
+ task_id: The ID of the task to cancel.
502
+ """
503
+ tasks_file = ctx.obj["TASKS_FILE"]
504
+ if tasks.cancel_task(tasks_file, task_id):
505
+ click.echo(f"Task {task_id} cancelled.")
506
+ else:
507
+ click.echo(f"Error: Task {task_id} not found or not in progress.")
508
+ ctx.exit(1)
509
+
510
+
511
+ @cli.command(short_help="<taskid> Clear a task's attempts and progress")
512
+ @click.argument("task_id")
513
+ @click.pass_context
514
+ def reset(ctx: click.Context, task_id: str):
515
+ """Clears all history (attempts, progress, and logs) for a specific task.
516
+
517
+ Args:
518
+ ctx: The click context.
519
+ task_id: The ID of the task to reset.
520
+ """
521
+ tasks_file = ctx.obj["TASKS_FILE"]
522
+ try:
523
+ target_task = tasks.reset_task(tasks_file, task_id)
524
+ click.echo(
525
+ f"Task {target_task.id} attempts, progress, and logs cleared."
526
+ )
527
+ except ValueError as e:
528
+ click.echo(f"Error: {e}")
529
+ ctx.exit(1)
@@ -0,0 +1,168 @@
1
+ import pathlib
2
+ import shutil
3
+ import tempfile
4
+ import unittest
5
+ from unittest import mock
6
+
7
+ import click.testing
8
+
9
+ from lemming import cli, tasks
10
+
11
+
12
+ class TestCLITasks(unittest.TestCase):
13
+ def setUp(self):
14
+ self.cli_runner = click.testing.CliRunner()
15
+ self.test_dir = tempfile.mkdtemp()
16
+ self.test_tasks_file = pathlib.Path(self.test_dir) / "tasks_test.yml"
17
+ self.base_args = [
18
+ "--verbose",
19
+ "--tasks-file",
20
+ str(self.test_tasks_file),
21
+ ]
22
+
23
+ # Scaffold a valid file
24
+ data = tasks.Roadmap(
25
+ context="Initial context",
26
+ tasks=[
27
+ tasks.Task(
28
+ id="12345678",
29
+ description="Initial Task",
30
+ status=tasks.TaskStatus.PENDING,
31
+ attempts=0,
32
+ progress=[],
33
+ )
34
+ ],
35
+ )
36
+ tasks.save_tasks(self.test_tasks_file, data)
37
+
38
+ def tearDown(self):
39
+ shutil.rmtree(self.test_dir)
40
+
41
+ def test_add_task(self):
42
+ result = self.cli_runner.invoke(
43
+ cli.cli, self.base_args + ["add", "New Task"]
44
+ )
45
+ self.assertEqual(result.exit_code, 0)
46
+ self.assertIn("Added task", result.output)
47
+
48
+ data = tasks.load_tasks(self.test_tasks_file)
49
+ task_descs = [t.description for t in data.tasks]
50
+ self.assertIn("New Task", task_descs)
51
+
52
+ def test_edit_task_description(self):
53
+ result = self.cli_runner.invoke(
54
+ cli.cli,
55
+ self.base_args
56
+ + ["edit", "12345678", "--description", "Updated Task"],
57
+ )
58
+ self.assertEqual(result.exit_code, 0)
59
+ self.assertIn("Task 12345678 updated.", result.output)
60
+
61
+ data = tasks.load_tasks(self.test_tasks_file)
62
+ self.assertEqual(data.tasks[0].description, "Updated Task")
63
+
64
+ def test_delete_task(self):
65
+ self.cli_runner.invoke(
66
+ cli.cli, self.base_args + ["add", "To be removed"]
67
+ )
68
+
69
+ data = tasks.load_tasks(self.test_tasks_file)
70
+ task_id = next(
71
+ t.id for t in data.tasks if t.description == "To be removed"
72
+ )
73
+
74
+ delete_result = self.cli_runner.invoke(
75
+ cli.cli, self.base_args + ["delete", task_id]
76
+ )
77
+ self.assertEqual(delete_result.exit_code, 0)
78
+ self.assertIn("Removed task", delete_result.output)
79
+
80
+ data = tasks.load_tasks(self.test_tasks_file)
81
+ task_descs = [t.description for t in data.tasks]
82
+ self.assertNotIn("To be removed", task_descs)
83
+
84
+ def test_status_command(self):
85
+ result = self.cli_runner.invoke(cli.cli, self.base_args + ["status"])
86
+ self.assertEqual(result.exit_code, 0)
87
+ self.assertIn("Loop Status: Idle", result.output)
88
+ self.assertIn("Initial Task", result.output)
89
+
90
+ # Verify Running state (mocking is_loop_running)
91
+ with mock.patch(
92
+ "lemming.tasks.lifecycle.is_loop_running", return_value=True
93
+ ):
94
+ result = self.cli_runner.invoke(
95
+ cli.cli, self.base_args + ["status"]
96
+ )
97
+ self.assertIn("Loop Status: Running", result.output)
98
+
99
+ def test_logs_command_fail_no_logs(self):
100
+ result = self.cli_runner.invoke(
101
+ cli.cli, self.base_args + ["logs", "12345678"]
102
+ )
103
+ self.assertNotEqual(result.exit_code, 0)
104
+ self.assertIn("No log for task", result.output)
105
+
106
+ def test_task_complete(self):
107
+ self.cli_runner.invoke(
108
+ cli.cli, self.base_args + ["progress", "12345678", "Done"]
109
+ )
110
+ result = self.cli_runner.invoke(
111
+ cli.cli, self.base_args + ["complete", "12345678"]
112
+ )
113
+ self.assertEqual(result.exit_code, 0)
114
+
115
+ data = tasks.load_tasks(self.test_tasks_file)
116
+ self.assertEqual(data.tasks[0].status, tasks.TaskStatus.COMPLETED)
117
+
118
+ def test_task_uncomplete(self):
119
+ # First complete it
120
+ self.cli_runner.invoke(
121
+ cli.cli, self.base_args + ["progress", "12345678", "Done"]
122
+ )
123
+ self.cli_runner.invoke(
124
+ cli.cli, self.base_args + ["complete", "12345678"]
125
+ )
126
+
127
+ # Then uncomplete
128
+ result = self.cli_runner.invoke(
129
+ cli.cli, self.base_args + ["uncomplete", "12345678"]
130
+ )
131
+ self.assertEqual(result.exit_code, 0)
132
+ data = tasks.load_tasks(self.test_tasks_file)
133
+ self.assertEqual(data.tasks[0].status, tasks.TaskStatus.PENDING)
134
+
135
+ def test_task_fail(self):
136
+ self.cli_runner.invoke(
137
+ cli.cli, self.base_args + ["progress", "12345678", "Failed reason"]
138
+ )
139
+ result = self.cli_runner.invoke(
140
+ cli.cli, self.base_args + ["fail", "12345678"]
141
+ )
142
+ self.assertEqual(result.exit_code, 0)
143
+ data = tasks.load_tasks(self.test_tasks_file)
144
+ self.assertEqual(data.tasks[0].status, tasks.TaskStatus.FAILED)
145
+
146
+ def test_cancel_command(self):
147
+ # We need a fake in-progress task for this
148
+ with tasks.lock_tasks(self.test_tasks_file):
149
+ data = tasks.load_tasks(self.test_tasks_file)
150
+ data.tasks[0].status = tasks.TaskStatus.IN_PROGRESS
151
+ tasks.save_tasks(self.test_tasks_file, data)
152
+
153
+ result = self.cli_runner.invoke(
154
+ cli.cli, self.base_args + ["cancel", "12345678"]
155
+ )
156
+ self.assertEqual(result.exit_code, 0)
157
+ self.assertIn("Task 12345678 cancelled.", result.output)
158
+
159
+ def test_reset_command(self):
160
+ result = self.cli_runner.invoke(
161
+ cli.cli, self.base_args + ["reset", "12345678"]
162
+ )
163
+ self.assertEqual(result.exit_code, 0)
164
+ self.assertIn("attempts, progress, and logs cleared", result.output)
165
+
166
+
167
+ if __name__ == "__main__":
168
+ unittest.main()
lemming/cli.py ADDED
@@ -0,0 +1,5 @@
1
+ """Re-exports the Lemming CLI entry point."""
2
+
3
+ from .cli import cli
4
+
5
+ __all__ = ["cli"]