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.
- lemming/__init__.py +1 -0
- lemming/api/__init__.py +5 -0
- lemming/api/auth.py +34 -0
- lemming/api/auth_test.py +40 -0
- lemming/api/config.py +85 -0
- lemming/api/config_test.py +84 -0
- lemming/api/conftest.py +204 -0
- lemming/api/context.py +39 -0
- lemming/api/context_test.py +62 -0
- lemming/api/directories.py +57 -0
- lemming/api/directories_test.py +94 -0
- lemming/api/files.py +116 -0
- lemming/api/files_test.py +163 -0
- lemming/api/hooks.py +15 -0
- lemming/api/hooks_test.py +33 -0
- lemming/api/logging.py +16 -0
- lemming/api/logging_test.py +59 -0
- lemming/api/loop.py +45 -0
- lemming/api/loop_test.py +58 -0
- lemming/api/main.py +53 -0
- lemming/api/main_test.py +30 -0
- lemming/api/tasks.py +170 -0
- lemming/api/tasks_test.py +426 -0
- lemming/api.py +5 -0
- lemming/api_test.py +7 -0
- lemming/cli/__init__.py +12 -0
- lemming/cli/config.py +67 -0
- lemming/cli/config_test.py +50 -0
- lemming/cli/context.py +40 -0
- lemming/cli/context_test.py +49 -0
- lemming/cli/hooks.py +147 -0
- lemming/cli/hooks_test.py +50 -0
- lemming/cli/main.py +42 -0
- lemming/cli/main_test.py +21 -0
- lemming/cli/operations.py +226 -0
- lemming/cli/operations_test.py +44 -0
- lemming/cli/progress.py +54 -0
- lemming/cli/progress_test.py +40 -0
- lemming/cli/readability_cli.py +28 -0
- lemming/cli/readability_cli_test.py +57 -0
- lemming/cli/tasks.py +529 -0
- lemming/cli/tasks_test.py +168 -0
- lemming/cli.py +5 -0
- lemming/cli_test.py +22 -0
- lemming/conftest.py +13 -0
- lemming/hooks.py +145 -0
- lemming/hooks_test.py +180 -0
- lemming/integration_test.py +299 -0
- lemming/main.py +6 -0
- lemming/main_test.py +44 -0
- lemming/models.py +88 -0
- lemming/models_test.py +91 -0
- lemming/orchestrator.py +407 -0
- lemming/orchestrator_test.py +468 -0
- lemming/paths.py +245 -0
- lemming/paths_test.py +186 -0
- lemming/persistence.py +179 -0
- lemming/persistence_test.py +150 -0
- lemming/prompts/hooks/readability.md +42 -0
- lemming/prompts/hooks/roadmap.md +61 -0
- lemming/prompts/hooks/testing.md +44 -0
- lemming/prompts/taskrunner.md +69 -0
- lemming/prompts.py +354 -0
- lemming/prompts_test.py +421 -0
- lemming/providers.py +190 -0
- lemming/providers_test.py +73 -0
- lemming/runner.py +327 -0
- lemming/runner_test.py +378 -0
- lemming/tasks/__init__.py +75 -0
- lemming/tasks/lifecycle.py +331 -0
- lemming/tasks/lifecycle_test.py +312 -0
- lemming/tasks/operations.py +258 -0
- lemming/tasks/operations_test.py +172 -0
- lemming/tasks/progress.py +29 -0
- lemming/tasks/progress_test.py +22 -0
- lemming/tasks/queries.py +128 -0
- lemming/tasks/queries_test.py +233 -0
- lemming/web/dashboard.spec.js +350 -0
- lemming/web/dashboard.test.js +998 -0
- lemming/web/favicon.js +40 -0
- lemming/web/favicon.spec.js +242 -0
- lemming/web/files.html +375 -0
- lemming/web/files.spec.js +97 -0
- lemming/web/index.html +983 -0
- lemming/web/index.js +753 -0
- lemming/web/logs.html +358 -0
- lemming/web/logs.test.js +195 -0
- lemming/web/mancha.js +58 -0
- lemming/web/screenshots.spec.js +328 -0
- lemming_cli-0.1.0.dist-info/METADATA +314 -0
- lemming_cli-0.1.0.dist-info/RECORD +94 -0
- lemming_cli-0.1.0.dist-info/WHEEL +4 -0
- lemming_cli-0.1.0.dist-info/entry_points.txt +2 -0
- 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()
|